From 86eee54a46d06ae2966cf6378a3b93d2c59f48f8 Mon Sep 17 00:00:00 2001 From: Midhun A Darvin Date: Wed, 24 Sep 2025 22:19:22 +0530 Subject: [PATCH 01/14] feat: add `contentId` to builder class --- .changeset/khaki-poets-chew.md | 6 ++++++ packages/core/src/builder.class.ts | 9 +++++++++ .../react/src/components/builder-component.component.tsx | 3 +++ .../react/src/components/builder-content.component.tsx | 1 + 4 files changed, 19 insertions(+) create mode 100644 .changeset/khaki-poets-chew.md diff --git a/.changeset/khaki-poets-chew.md b/.changeset/khaki-poets-chew.md new file mode 100644 index 00000000000..db124b76440 --- /dev/null +++ b/.changeset/khaki-poets-chew.md @@ -0,0 +1,6 @@ +--- +"@builder.io/react": patch +"@builder.io/sdk": patch +--- + +feat: add `contentId` to builder class diff --git a/packages/core/src/builder.class.ts b/packages/core/src/builder.class.ts index 007425b794c..27998610dbe 100644 --- a/packages/core/src/builder.class.ts +++ b/packages/core/src/builder.class.ts @@ -1373,6 +1373,7 @@ export class Builder { private hasOverriddenCanTrack = false; private apiKey$ = new BehaviorSubject(null); private authToken$ = new BehaviorSubject(null); + private contentId$ = new BehaviorSubject(null); userAttributesChanged = new BehaviorSubject(null); @@ -1708,6 +1709,14 @@ export class Builder { this.apiKey$.next(key); } + get contentId() { + return this.contentId$.value; + } + + set contentId(id: string | null) { + this.contentId$.next(id); + } + get authToken() { return this.authToken$.value; } diff --git a/packages/react/src/components/builder-component.component.tsx b/packages/react/src/components/builder-component.component.tsx index 72e3aacdd74..91c018b4b94 100644 --- a/packages/react/src/components/builder-component.component.tsx +++ b/packages/react/src/components/builder-component.component.tsx @@ -1374,6 +1374,9 @@ export class BuilderComponent extends React.Component< } onContentLoaded = (data: any, content: Content) => { + if (content && content.id) { + this.builder.contentId = content.id; + } if (this.name === 'page' && Builder.isBrowser) { if (data) { const { title, pageTitle, description, pageDescription } = data; diff --git a/packages/react/src/components/builder-content.component.tsx b/packages/react/src/components/builder-content.component.tsx index e17e940c363..e00180b1ff9 100644 --- a/packages/react/src/components/builder-content.component.tsx +++ b/packages/react/src/components/builder-content.component.tsx @@ -183,6 +183,7 @@ export class BuilderContent extends React.Comp const contentData = this.options.initialContent[0]; // TODO: intersectionobserver like in subscribetocontent - reuse the logic if (contentData?.id) { + this.builder.contentId = contentData.id; this.builder.trackImpression(contentData.id, this.renderedVariantId, undefined, { content: contentData, }); From 3c76be215c1fb713954a85eaedd55308d9862a6c Mon Sep 17 00:00:00 2001 From: Midhun A Darvin Date: Thu, 25 Sep 2025 20:27:41 +0530 Subject: [PATCH 02/14] fix: conversion tracking for gen2 sdks --- .changeset/khaki-poets-chew.md | 6 -- .changeset/wild-ravens-swim.md | 14 +++ package.json | 3 +- packages/sdks/docs/DEVELOP.md | 13 +++ .../src/components/content/content.lite.tsx | 13 +++ .../sdks/src/functions/content-variants.ts | 50 ++++++++++ packages/sdks/src/functions/cookie.ts | 43 +++++++++ .../sdks/src/functions/evaluate/helpers.ts | 60 ++++++++++++ packages/sdks/src/functions/global-context.ts | 94 +++++++++++++++++++ 9 files changed, 289 insertions(+), 7 deletions(-) delete mode 100644 .changeset/khaki-poets-chew.md create mode 100644 .changeset/wild-ravens-swim.md create mode 100644 packages/sdks/src/functions/content-variants.ts create mode 100644 packages/sdks/src/functions/cookie.ts create mode 100644 packages/sdks/src/functions/global-context.ts diff --git a/.changeset/khaki-poets-chew.md b/.changeset/khaki-poets-chew.md deleted file mode 100644 index db124b76440..00000000000 --- a/.changeset/khaki-poets-chew.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@builder.io/react": patch -"@builder.io/sdk": patch ---- - -feat: add `contentId` to builder class diff --git a/.changeset/wild-ravens-swim.md b/.changeset/wild-ravens-swim.md new file mode 100644 index 00000000000..706426a8f90 --- /dev/null +++ b/.changeset/wild-ravens-swim.md @@ -0,0 +1,14 @@ +--- +"@builder.io/react": patch +"@builder.io/sdk": patch +"@builder.io/sdk-angular": patch +"@builder.io/sdk-react-nextjs": patch +"@builder.io/sdk-qwik": patch +"@builder.io/sdk-react": patch +"@builder.io/sdk-react-native": patch +"@builder.io/sdk-solid": patch +"@builder.io/sdk-svelte": patch +"@builder.io/sdk-vue": patch +--- + +fix: handle conversion tracking for gen1 and gen2 sdks diff --git a/package.json b/package.json index de785be691a..9b50aa40be3 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "lint:fix": "prettier --write '**/*.{js,jsx,ts,tsx}'", "update-npm-dependency": "zx ./scripts/update-npm-dependency.mjs", "g:changeset": "changeset", - "g:nx": "cd $INIT_CWD && nx" + "g:nx": "cd $INIT_CWD && nx", + "watch:sdk": "npx watch \"yarn g:nx build $0\" packages/sdks/src packages/sdks/overrides" }, "engines": { "yarn": ">= 3.0.0" diff --git a/packages/sdks/docs/DEVELOP.md b/packages/sdks/docs/DEVELOP.md index 2f56537493b..6a96f2c8d7a 100644 --- a/packages/sdks/docs/DEVELOP.md +++ b/packages/sdks/docs/DEVELOP.md @@ -78,6 +78,19 @@ You can fetch real data from the Builder API instead of using the JSON mock file To unlink the SDK from your project, all you have to do is run `npm install` in your project folder. That will clear all sym-links. +*Alternative Method* : + +- Build the SDK using `yarn g:nx build @builder.io/sdk-react` (replace `react` with the SDK you want to build) +- In your project temporarily replace the sdk dependency as follows: + + ``` + "@builder.io/sdk-react": "link:/packages/sdks/output/react", + ``` +- `yarn install` in your project folder +- If you want your sdk to refresh when making changes in the `packages/sdks/src` folder, run the command in root folder `yarn run watch:sdk @builder.io/sdk-react` (replace `react` with the SDK you want to build) + +This achieves the same result as the npm link method above, but uses yarn instead. + **NOTE: Testing React-Native SDK in iOS Simulator** One big caveat is that the iOS Simulator does not support sym-linked packages. To workaround this, you will have to copy the SDK folder. This means that you will need to manually do so every time you want a new change to be reflected. in the react-native example, there is a handy `yarn run cp-sdk` command to do that for you. diff --git a/packages/sdks/src/components/content/content.lite.tsx b/packages/sdks/src/components/content/content.lite.tsx index 81ef2c24817..6d291a30547 100644 --- a/packages/sdks/src/components/content/content.lite.tsx +++ b/packages/sdks/src/components/content/content.lite.tsx @@ -15,7 +15,10 @@ import type { BuilderRenderState, RegisteredComponents, } from '../../context/types.js'; +import { setTestsFromUrl } from '../../functions/content-variants.js'; import { evaluate } from '../../functions/evaluate/evaluate.js'; +import { setGlobalBuilderContext } from '../../functions/global-context.js'; +import { isBrowser } from '../../functions/is-browser.js'; import { serializeIncludingFunctions } from '../../functions/register-component.js'; import { logger } from '../../helpers/logger.js'; import type { ComponentInfo } from '../../types/components.js'; @@ -139,6 +142,16 @@ export default function ContentComponent(props: ContentProps) { ); } + setGlobalBuilderContext({ + apiKey: props.apiKey, + apiHost: props.apiHost, + contentId: builderContextSignal.value.content?.id, + }); + + if (isBrowser()) { + setTestsFromUrl(); + } + // run any dynamic JS code attached to content const jsCode = builderContextSignal.value.content?.data?.jsCode; diff --git a/packages/sdks/src/functions/content-variants.ts b/packages/sdks/src/functions/content-variants.ts new file mode 100644 index 00000000000..dd002085acf --- /dev/null +++ b/packages/sdks/src/functions/content-variants.ts @@ -0,0 +1,50 @@ +import { setCookie } from './cookie'; + +const testCookiePrefix = 'builder.tests'; + +function parseUrlParams(url: string): Map { + const result = new Map(); + + try { + const urlObj = new URL(url); + const params = urlObj.searchParams; + + for (const [key, value] of params) { + result.set(key, value); + } + } catch (error) { + console.debug('Error parsing URL parameters:', error); + } + + return result; +} + +export function setTestCookie(contentId: string, variationId: string) { + // 30 days from now + const future = new Date(); + future.setDate(future.getDate() + 30); + + // Use the native setCookie function directly + if (typeof window !== 'undefined') { + setCookie(`${testCookiePrefix}.${contentId}`, variationId, future); + } +} + +export function setTestsFromUrl() { + if (typeof window === 'undefined') return; + + try { + // Use native URL object to parse current page URL + const params = parseUrlParams(window.location.href); + + // Look for parameters that start with 'builder.tests.' + for (const [key, value] of params) { + if (key.startsWith(`${testCookiePrefix}.`)) { + const testKey = key.replace(`${testCookiePrefix}.`, ''); + setTestCookie(testKey, value); + } + } + } catch (e) { + console.debug('Error parsing tests from URL', e); + } +} diff --git a/packages/sdks/src/functions/cookie.ts b/packages/sdks/src/functions/cookie.ts new file mode 100644 index 00000000000..bf2f7e3719a --- /dev/null +++ b/packages/sdks/src/functions/cookie.ts @@ -0,0 +1,43 @@ +import { isBrowser } from './is-browser'; + +export function setCookie(name: string, value: string, expires?: Date) { + try { + let expiresString = ''; + + // TODO: need to know if secure server side + if (expires) { + expiresString = '; expires=' + expires.toUTCString(); + } + + const secure = isBrowser() ? location.protocol === 'https:' : true; + document.cookie = + name + + '=' + + (value || '') + + expiresString + + '; path=/' + + (secure ? '; secure; SameSite=None' : ''); + } catch (err) { + console.warn('Could not set cookie', err); + } +} + +export function getCookie(name: string) { + try { + return ( + decodeURIComponent( + document.cookie.replace( + new RegExp( + '(?:(?:^|.*;)\\s*' + + encodeURIComponent(name).replace(/[-.+*]/g, '\\$&') + + '\\s*\\=\\s*([^;]*).*$)|^.*$' + ), + '$1' + ) + ) || null + ); + } catch (err) { + console.warn('Could not get cookie', err); + return null; + } +} diff --git a/packages/sdks/src/functions/evaluate/helpers.ts b/packages/sdks/src/functions/evaluate/helpers.ts index ce113bc328d..b2157229264 100644 --- a/packages/sdks/src/functions/evaluate/helpers.ts +++ b/packages/sdks/src/functions/evaluate/helpers.ts @@ -2,8 +2,13 @@ import type { BuilderContextInterface, BuilderRenderState, } from '../../context/types.js'; +import { getDefaultCanTrack } from '../../helpers/canTrack.js'; +import { getCookie } from '../cookie.js'; +import { getGlobalBuilderContext } from '../global-context.js'; import { isBrowser } from '../is-browser.js'; import { isEditing } from '../is-editing.js'; +import type { EventProps } from '../track'; +import { _track } from '../track'; import { getUserAttributes } from '../track/helpers.js'; export type EvaluatorArgs = Omit & { @@ -15,6 +20,21 @@ export type BuilderGlobals = { isBrowser: boolean | undefined; isServer: boolean | undefined; getUserAttributes: typeof getUserAttributes; + apiKey: string | undefined; + contentId: string | undefined; + getCookie: typeof getCookie; + track: ( + eventName: string, + properties: Partial, + context?: any + ) => void; + trackConversion: ( + amount?: number, + contentId?: string | any, + variationId?: string, + customProperties?: any, + context?: any + ) => void; }; export type ExecutorArgs = Pick< @@ -52,6 +72,46 @@ export const getBuilderGlobals = (): BuilderGlobals => ({ isBrowser: isBrowser(), isServer: !isBrowser(), getUserAttributes: () => getUserAttributes(), + apiKey: getGlobalBuilderContext().apiKey, + contentId: getGlobalBuilderContext().contentId, + getCookie: (args) => getCookie(args), + track: ( + eventName: string, + properties: Partial = {}, + context?: any + ) => { + const builderContext = getGlobalBuilderContext(); + _track({ + type: eventName, + ...properties, + apiHost: builderContext?.apiHost, + apiKey: builderContext?.apiKey || '', + context, + canTrack: getDefaultCanTrack(properties.canTrack), + }); + }, + trackConversion: ( + amount?: number, + contentId?: string, + variationId?: string, + customProperties?: any, + context?: any + ) => { + const meta = typeof contentId === 'object' ? contentId : customProperties; + const useContentId = typeof contentId === 'string' ? contentId : undefined; + const builderContext = getGlobalBuilderContext(); + _track({ + type: 'conversion', + apiHost: builderContext?.apiHost, + apiKey: builderContext?.apiKey || '', + amount: amount || undefined, + contentId: useContentId, + variationId: variationId || undefined, + meta, + context: context || undefined, + canTrack: getDefaultCanTrack(), + }); + }, }); export const parseCode = ( diff --git a/packages/sdks/src/functions/global-context.ts b/packages/sdks/src/functions/global-context.ts new file mode 100644 index 00000000000..0f88f2517fc --- /dev/null +++ b/packages/sdks/src/functions/global-context.ts @@ -0,0 +1,94 @@ +/** + * Global Builder context singleton to store and retrieve Builder configuration + * across the application without prop drilling. + */ + +export interface GlobalBuilderContext { + apiKey?: string; + apiHost?: string; + contentId?: string; +} + +/** + * Singleton instance to store the global Builder context + */ +class BuilderGlobalContext { + private static instance: BuilderGlobalContext; + private context: GlobalBuilderContext = {}; + + private constructor() {} + + /** + * Get the singleton instance + */ + public static getInstance(): BuilderGlobalContext { + if (!BuilderGlobalContext.instance) { + BuilderGlobalContext.instance = new BuilderGlobalContext(); + } + return BuilderGlobalContext.instance; + } + + /** + * Set the global context values + */ + public setContext(context: GlobalBuilderContext): void { + this.context = { ...this.context, ...context }; + } + + /** + * Get the current global context + */ + public getContext(): GlobalBuilderContext { + return { ...this.context }; + } + + /** + * Clear the global context + */ + public clearContext(): void { + this.context = {}; + } + + /** + * Get a specific value from the context + */ + public getValue( + key: K + ): GlobalBuilderContext[K] { + return this.context[key]; + } +} + +/** + * Set the global Builder context + * @param context - The context values to set + */ +export function setGlobalBuilderContext(context: GlobalBuilderContext): void { + BuilderGlobalContext.getInstance().setContext(context); +} + +/** + * Get the global Builder context + * @returns The current global Builder context + */ +export function getGlobalBuilderContext(): GlobalBuilderContext { + return BuilderGlobalContext.getInstance().getContext(); +} + +/** + * Get a specific value from the global Builder context + * @param key - The key to retrieve + * @returns The value for the specified key + */ +export function getGlobalBuilderValue( + key: K +): GlobalBuilderContext[K] { + return BuilderGlobalContext.getInstance().getValue(key); +} + +/** + * Clear the global Builder context + */ +export function clearGlobalBuilderContext(): void { + BuilderGlobalContext.getInstance().clearContext(); +} From ffc91574e3775f06ac05d7fba3c84ced972186f7 Mon Sep 17 00:00:00 2001 From: Midhun A Darvin Date: Thu, 25 Sep 2025 21:27:17 +0530 Subject: [PATCH 03/14] fix: review comment - figure out content id and variant id from the sdk itself --- packages/core/src/builder.class.ts | 25 +++++++++++++++++-- .../sdks/src/functions/content-variants.ts | 8 ++++-- packages/sdks/src/functions/cookie.ts | 2 +- .../sdks/src/functions/evaluate/helpers.ts | 18 +++++++++++-- 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/packages/core/src/builder.class.ts b/packages/core/src/builder.class.ts index 27998610dbe..4296c0a2391 100644 --- a/packages/core/src/builder.class.ts +++ b/packages/core/src/builder.class.ts @@ -1602,9 +1602,30 @@ export class Builder { return; } const meta = typeof contentId === 'object' ? contentId : customProperties; - const useContentId = typeof contentId === 'string' ? contentId : undefined; + let useContentId = typeof contentId === 'string' ? contentId : undefined; - this.track('conversion', { amount, variationId, meta, contentId: useContentId }, context); + if (!useContentId && this.contentId) { + useContentId = this.contentId; + } + + let useVariationId = variationId; + if (!useVariationId && useContentId) { + useVariationId = this.getTestCookie(useContentId); + } + + this.track( + 'conversion', + { + amount, + variationId: + useVariationId && useContentId && useVariationId !== useContentId + ? useVariationId + : undefined, + meta, + contentId: useContentId, + }, + context + ); } autoTrack = !Builder.isBrowser diff --git a/packages/sdks/src/functions/content-variants.ts b/packages/sdks/src/functions/content-variants.ts index dd002085acf..4f7611cde7e 100644 --- a/packages/sdks/src/functions/content-variants.ts +++ b/packages/sdks/src/functions/content-variants.ts @@ -1,6 +1,10 @@ -import { setCookie } from './cookie'; +import { getCookie, setCookie } from './cookie.js'; -const testCookiePrefix = 'builder.tests'; +export const testCookiePrefix = 'builder.tests'; + +export function getTestCookie(name: string) { + return getCookie(`${testCookiePrefix}.${name}`); +} function parseUrlParams(url: string): Map { const result = new Map(); diff --git a/packages/sdks/src/functions/cookie.ts b/packages/sdks/src/functions/cookie.ts index bf2f7e3719a..28640808314 100644 --- a/packages/sdks/src/functions/cookie.ts +++ b/packages/sdks/src/functions/cookie.ts @@ -1,4 +1,4 @@ -import { isBrowser } from './is-browser'; +import { isBrowser } from './is-browser.js'; export function setCookie(name: string, value: string, expires?: Date) { try { diff --git a/packages/sdks/src/functions/evaluate/helpers.ts b/packages/sdks/src/functions/evaluate/helpers.ts index b2157229264..3231a741380 100644 --- a/packages/sdks/src/functions/evaluate/helpers.ts +++ b/packages/sdks/src/functions/evaluate/helpers.ts @@ -3,6 +3,7 @@ import type { BuilderRenderState, } from '../../context/types.js'; import { getDefaultCanTrack } from '../../helpers/canTrack.js'; +import { getTestCookie } from '../content-variants.js'; import { getCookie } from '../cookie.js'; import { getGlobalBuilderContext } from '../global-context.js'; import { isBrowser } from '../is-browser.js'; @@ -98,15 +99,28 @@ export const getBuilderGlobals = (): BuilderGlobals => ({ context?: any ) => { const meta = typeof contentId === 'object' ? contentId : customProperties; - const useContentId = typeof contentId === 'string' ? contentId : undefined; + let useContentId = typeof contentId === 'string' ? contentId : undefined; const builderContext = getGlobalBuilderContext(); + + if (!useContentId && builderContext?.contentId) { + useContentId = builderContext.contentId; + } + + let useVariationId = variationId; + if (!useVariationId && useContentId) { + useVariationId = getTestCookie(useContentId) || undefined; + } + _track({ type: 'conversion', apiHost: builderContext?.apiHost, apiKey: builderContext?.apiKey || '', amount: amount || undefined, contentId: useContentId, - variationId: variationId || undefined, + variationId: + useVariationId && useContentId && useVariationId !== useContentId + ? useVariationId + : undefined, meta, context: context || undefined, canTrack: getDefaultCanTrack(), From d3787e7bdbfd1262c808684d5823bf1c50c5294e Mon Sep 17 00:00:00 2001 From: Midhun A Darvin Date: Thu, 25 Sep 2025 21:39:41 +0530 Subject: [PATCH 04/14] - remove items from getBuilderGlobals --- packages/sdks/src/functions/evaluate/helpers.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/sdks/src/functions/evaluate/helpers.ts b/packages/sdks/src/functions/evaluate/helpers.ts index 3231a741380..83ec7307777 100644 --- a/packages/sdks/src/functions/evaluate/helpers.ts +++ b/packages/sdks/src/functions/evaluate/helpers.ts @@ -4,7 +4,6 @@ import type { } from '../../context/types.js'; import { getDefaultCanTrack } from '../../helpers/canTrack.js'; import { getTestCookie } from '../content-variants.js'; -import { getCookie } from '../cookie.js'; import { getGlobalBuilderContext } from '../global-context.js'; import { isBrowser } from '../is-browser.js'; import { isEditing } from '../is-editing.js'; @@ -21,9 +20,6 @@ export type BuilderGlobals = { isBrowser: boolean | undefined; isServer: boolean | undefined; getUserAttributes: typeof getUserAttributes; - apiKey: string | undefined; - contentId: string | undefined; - getCookie: typeof getCookie; track: ( eventName: string, properties: Partial, @@ -73,9 +69,6 @@ export const getBuilderGlobals = (): BuilderGlobals => ({ isBrowser: isBrowser(), isServer: !isBrowser(), getUserAttributes: () => getUserAttributes(), - apiKey: getGlobalBuilderContext().apiKey, - contentId: getGlobalBuilderContext().contentId, - getCookie: (args) => getCookie(args), track: ( eventName: string, properties: Partial = {}, From 6267e52bdb314b2df774bd34504c9d083c4ad7c7 Mon Sep 17 00:00:00 2001 From: Midhun A Darvin Date: Thu, 25 Sep 2025 21:53:06 +0530 Subject: [PATCH 05/14] fix: build error --- packages/sdks/src/functions/evaluate/helpers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sdks/src/functions/evaluate/helpers.ts b/packages/sdks/src/functions/evaluate/helpers.ts index 83ec7307777..fb527c79c86 100644 --- a/packages/sdks/src/functions/evaluate/helpers.ts +++ b/packages/sdks/src/functions/evaluate/helpers.ts @@ -7,9 +7,9 @@ import { getTestCookie } from '../content-variants.js'; import { getGlobalBuilderContext } from '../global-context.js'; import { isBrowser } from '../is-browser.js'; import { isEditing } from '../is-editing.js'; -import type { EventProps } from '../track'; -import { _track } from '../track'; import { getUserAttributes } from '../track/helpers.js'; +import type { EventProps } from '../track/index.js'; +import { _track } from '../track/index.js'; export type EvaluatorArgs = Omit & { event?: Event; From 835c56063ba11c3c14893e3852c3c4a078893b6c Mon Sep 17 00:00:00 2001 From: Midhun A Darvin Date: Fri, 26 Sep 2025 15:07:31 +0530 Subject: [PATCH 06/14] fix: review comment --- .../sdks/src/functions/content-variants.ts | 14 ++++-- packages/sdks/src/functions/cookie.ts | 43 ------------------- 2 files changed, 11 insertions(+), 46 deletions(-) delete mode 100644 packages/sdks/src/functions/cookie.ts diff --git a/packages/sdks/src/functions/content-variants.ts b/packages/sdks/src/functions/content-variants.ts index 4f7611cde7e..9c750e5df04 100644 --- a/packages/sdks/src/functions/content-variants.ts +++ b/packages/sdks/src/functions/content-variants.ts @@ -1,9 +1,12 @@ -import { getCookie, setCookie } from './cookie.js'; +import { getCookieSync, setCookie } from '../helpers/cookie.js'; export const testCookiePrefix = 'builder.tests'; export function getTestCookie(name: string) { - return getCookie(`${testCookiePrefix}.${name}`); + return getCookieSync({ + name: `${testCookiePrefix}.${name}`, + canTrack: true, + }); } function parseUrlParams(url: string): Map { @@ -30,7 +33,12 @@ export function setTestCookie(contentId: string, variationId: string) { // Use the native setCookie function directly if (typeof window !== 'undefined') { - setCookie(`${testCookiePrefix}.${contentId}`, variationId, future); + setCookie({ + name: `${testCookiePrefix}.${contentId}`, + value: variationId, + expires: future, + canTrack: true, + }); } } diff --git a/packages/sdks/src/functions/cookie.ts b/packages/sdks/src/functions/cookie.ts deleted file mode 100644 index 28640808314..00000000000 --- a/packages/sdks/src/functions/cookie.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { isBrowser } from './is-browser.js'; - -export function setCookie(name: string, value: string, expires?: Date) { - try { - let expiresString = ''; - - // TODO: need to know if secure server side - if (expires) { - expiresString = '; expires=' + expires.toUTCString(); - } - - const secure = isBrowser() ? location.protocol === 'https:' : true; - document.cookie = - name + - '=' + - (value || '') + - expiresString + - '; path=/' + - (secure ? '; secure; SameSite=None' : ''); - } catch (err) { - console.warn('Could not set cookie', err); - } -} - -export function getCookie(name: string) { - try { - return ( - decodeURIComponent( - document.cookie.replace( - new RegExp( - '(?:(?:^|.*;)\\s*' + - encodeURIComponent(name).replace(/[-.+*]/g, '\\$&') + - '\\s*\\=\\s*([^;]*).*$)|^.*$' - ), - '$1' - ) - ) || null - ); - } catch (err) { - console.warn('Could not get cookie', err); - return null; - } -} From 9245f6d86800a7b05c7a06518e427eb3d58842d8 Mon Sep 17 00:00:00 2001 From: Midhun A Darvin Date: Mon, 29 Sep 2025 22:03:54 +0530 Subject: [PATCH 07/14] chore: add tests --- packages/core/src/builder.class.test.ts | 381 +++++++++++- .../src/e2e-tests/track-conversion.spec.ts | 495 ++++++++++++++++ packages/sdks-tests/src/specs/index.ts | 6 + .../src/specs/section-with-conversion.ts | 169 ++++++ .../src/specs/symbol-with-conversion.ts | 119 ++++ .../sdks-tests/src/specs/track-conversion.ts | 554 ++++++++++++++++++ .../src/components/content/content.lite.tsx | 13 +- .../src/functions/evaluate/helpers.test.ts | 259 +++++++- 8 files changed, 1988 insertions(+), 8 deletions(-) create mode 100644 packages/sdks-tests/src/e2e-tests/track-conversion.spec.ts create mode 100644 packages/sdks-tests/src/specs/section-with-conversion.ts create mode 100644 packages/sdks-tests/src/specs/symbol-with-conversion.ts create mode 100644 packages/sdks-tests/src/specs/track-conversion.ts diff --git a/packages/core/src/builder.class.test.ts b/packages/core/src/builder.class.test.ts index 6c65b362fb1..2b6a3e434e5 100644 --- a/packages/core/src/builder.class.test.ts +++ b/packages/core/src/builder.class.test.ts @@ -1,4 +1,4 @@ -import { Builder, GetContentOptions } from './builder.class'; +import { Builder } from './builder.class'; import { BehaviorSubject } from './classes/observable.class'; import { BuilderContent } from './types/content'; @@ -1293,3 +1293,382 @@ describe('getAll', () => { ); }); }); + +describe('Builder.trackConversion', () => { + let builder: Builder; + let mockTrack: jest.SpyInstance; + let mockGetTestCookie: jest.SpyInstance; + + beforeEach(() => { + // Reset Builder static properties + Builder.isPreviewing = false; + + // Create a fresh Builder instance + builder = new Builder('test-api-key'); + + // Mock the track method + mockTrack = jest.spyOn(builder, 'track').mockImplementation(() => {}); + + // Mock the getTestCookie method + mockGetTestCookie = jest.spyOn(builder as any, 'getTestCookie').mockReturnValue(undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('early return conditions', () => { + it('should return early when Builder.isPreviewing is true', () => { + Builder.isPreviewing = true; + + (builder.trackConversion as any)(100, 'content-123'); + + expect(mockTrack).not.toHaveBeenCalled(); + }); + }); + + describe('parameter handling', () => { + it('should call track with all parameters provided', () => { + const amount = 100; + const contentId = 'content-123'; + const variationId = 'variation-456'; + const customProperties = { product: 'shoes' }; + const context = { userId: '789' }; + + (builder.trackConversion as any)(amount, contentId, variationId, customProperties, context); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 100, + contentId: 'content-123', + variationId: 'variation-456', + meta: { product: 'shoes' }, + }, + { userId: '789' } + ); + }); + + it('should handle minimal parameters', () => { + builder.trackConversion(); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: undefined, + contentId: undefined, + variationId: undefined, + meta: undefined, + }, + undefined + ); + }); + + it('should handle two-parameter overload (amount and customProperties)', () => { + const customProperties = { category: 'electronics' }; + + builder.trackConversion(75, customProperties); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 75, + contentId: undefined, + variationId: undefined, + meta: { category: 'electronics' }, + }, + undefined + ); + }); + }); + + describe('contentId logic', () => { + it('should use string contentId when provided', () => { + builder.trackConversion(100, 'explicit-content-id'); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 100, + contentId: 'explicit-content-id', + variationId: undefined, + meta: undefined, + }, + undefined + ); + }); + + it('should handle contentId as object (legacy format)', () => { + const metaObject = { product: 'laptop', brand: 'Apple' }; + + (builder.trackConversion as any)(1500, metaObject, 'variation-123'); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 1500, + contentId: undefined, + variationId: undefined, // undefined because contentId is undefined + meta: metaObject, + }, + undefined + ); + }); + + it('should fallback to this.contentId when no contentId provided', () => { + builder.contentId = 'builder-instance-content-id'; + + builder.trackConversion(200); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 200, + contentId: 'builder-instance-content-id', + variationId: undefined, + meta: undefined, + }, + undefined + ); + }); + + it('should prioritize explicit contentId over this.contentId', () => { + builder.contentId = 'builder-instance-content-id'; + + (builder.trackConversion as any)(300, 'explicit-content-id'); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 300, + contentId: 'explicit-content-id', + variationId: undefined, + meta: undefined, + }, + undefined + ); + }); + }); + + describe('variationId logic', () => { + it('should use provided variationId', () => { + (builder.trackConversion as any)(100, 'content-123', 'explicit-variation-456'); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 100, + contentId: 'content-123', + variationId: 'explicit-variation-456', + meta: undefined, + }, + undefined + ); + }); + + it('should get variationId from test cookie when not provided', () => { + mockGetTestCookie.mockReturnValue('cookie-variation-789'); + + (builder.trackConversion as any)(100, 'content-123'); + + expect(mockGetTestCookie).toHaveBeenCalledWith('content-123'); + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 100, + contentId: 'content-123', + variationId: 'cookie-variation-789', + meta: undefined, + }, + undefined + ); + }); + + it('should not get variationId from cookie when contentId is undefined', () => { + mockGetTestCookie.mockReturnValue('cookie-variation-789'); + + builder.trackConversion(100); + + expect(mockGetTestCookie).not.toHaveBeenCalled(); + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 100, + contentId: undefined, + variationId: undefined, + meta: undefined, + }, + undefined + ); + }); + + it('should set variationId to undefined when it equals contentId', () => { + (builder.trackConversion as any)(100, 'content-123', 'content-123'); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 100, + contentId: 'content-123', + variationId: undefined, + meta: undefined, + }, + undefined + ); + }); + + it('should set variationId to undefined when contentId is undefined', () => { + (builder.trackConversion as any)(100, undefined, 'variation-456'); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 100, + contentId: undefined, + variationId: undefined, + meta: undefined, + }, + undefined + ); + }); + + it('should prioritize explicit variationId over cookie value', () => { + mockGetTestCookie.mockReturnValue('cookie-variation-789'); + + (builder.trackConversion as any)(100, 'content-123', 'explicit-variation-456'); + + expect(mockGetTestCookie).not.toHaveBeenCalled(); + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 100, + contentId: 'content-123', + variationId: 'explicit-variation-456', + meta: undefined, + }, + undefined + ); + }); + }); + + describe('meta handling', () => { + it('should use customProperties as meta when contentId is string', () => { + const customProperties = { source: 'email', campaign: 'summer2023' }; + + (builder.trackConversion as any)(100, 'content-123', 'variation-456', customProperties); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 100, + contentId: 'content-123', + variationId: 'variation-456', + meta: customProperties, + }, + undefined + ); + }); + + it('should use contentId as meta when contentId is object', () => { + const metaObject = { product: 'subscription', tier: 'premium' }; + + builder.trackConversion(100, metaObject); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 100, + contentId: undefined, + variationId: undefined, + meta: metaObject, + }, + undefined + ); + }); + + it('should handle undefined meta', () => { + (builder.trackConversion as any)(100, 'content-123'); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 100, + contentId: 'content-123', + variationId: undefined, + meta: undefined, + }, + undefined + ); + }); + }); + + describe('complex scenarios', () => { + it('should handle contentId from instance with cookie variationId', () => { + builder.contentId = 'instance-content-456'; + mockGetTestCookie.mockReturnValue('cookie-variation-789'); + + (builder.trackConversion as any)(250, undefined, undefined, { source: 'organic' }); + + expect(mockGetTestCookie).toHaveBeenCalledWith('instance-content-456'); + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 250, + contentId: 'instance-content-456', + variationId: 'cookie-variation-789', + meta: { source: 'organic' }, + }, + undefined + ); + }); + + it('should handle all edge cases together', () => { + // Object contentId, no variationId, with context + const metaObject = { product: 'service', type: 'consultation' }; + const context = { referrer: 'google.com' }; + + (builder.trackConversion as any)(500, metaObject, undefined, undefined, context); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 500, + contentId: undefined, + variationId: undefined, + meta: metaObject, + }, + context + ); + }); + + it('should handle zero amount', () => { + builder.trackConversion(0, 'content-123'); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 0, + contentId: 'content-123', + variationId: undefined, + meta: undefined, + }, + undefined + ); + }); + + it('should handle negative amount', () => { + builder.trackConversion(-50, 'content-123'); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: -50, + contentId: 'content-123', + variationId: undefined, + meta: undefined, + }, + undefined + ); + }); + }); +}); diff --git a/packages/sdks-tests/src/e2e-tests/track-conversion.spec.ts b/packages/sdks-tests/src/e2e-tests/track-conversion.spec.ts new file mode 100644 index 00000000000..10169d2be1d --- /dev/null +++ b/packages/sdks-tests/src/e2e-tests/track-conversion.spec.ts @@ -0,0 +1,495 @@ +import type { Browser } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { excludeGen1, excludeRn, test } from '../helpers/index.js'; +import { CONVERSION_SYMBOL_CONTENT } from '../specs/symbol-with-conversion.js'; +import { CONVERSION_SECTION_CONTENT } from '../specs/section-with-conversion.js'; + +const COOKIE_NAME = 'builder.tests.test-content-id'; +const CONTENT_ID = 'test-content-id'; +const VARIANT_ID = 'test-variation-id'; + +const createContextWithCookies = async ({ + cookies, + baseURL, + browser, +}: { + browser: Browser; + baseURL: string; + cookies: { name: string; value: string }[]; +}) => { + const context = await browser.newContext({ + storageState: { + cookies: cookies.map(cookie => { + const newCookie = { + name: cookie.name, + value: cookie.value, + // this is valid but types seem to be mismatched. + url: baseURL, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + return newCookie; + }), + origins: [], + }, + }); + return context; +}; + +const initializeAbTest = async ( + { + page: _page, + baseURL, + packageName, + browser, + }: Pick< + Parameters[2]>[0], + 'page' | 'baseURL' | 'packageName' | 'browser' + >, + { cookieName, cookieValue }: { cookieName: string; cookieValue: string } +) => { + if (!baseURL) throw new Error('Missing baseURL'); + + // RN can't have SSR, we don't support/export it. + test.skip(packageName === 'react-native-74' || packageName === 'react-native-76-fabric'); + + /** + * This test is flaky on `nextjs-sdk-next-app` and `qwik-city`. Most likely because it is the very first test that runs. + */ + test.slow(packageName === 'nextjs-sdk-next-app' || packageName === 'qwik-city'); + + const context = await createContextWithCookies({ + baseURL, + browser, + cookies: [{ name: cookieName, value: cookieValue }], + }); + + const page = await context.newPage(); + + return { page }; +}; + +test.describe('Track Conversion', () => { + test.describe('Basic conversion tracking', () => { + // clear cookies before each test + test.beforeEach(async ({ page }) => { + await page.context().clearCookies(); + await page.reload(); + }); + + test('should track basic conversion with amount', async ({ + page, + sdk, + baseURL, + packageName, + browser, + }) => { + test.skip(excludeGen1(sdk) || excludeRn(sdk)); + + const { page: testPage } = await initializeAbTest( + { + page, + baseURL, + packageName, + browser, + }, + { cookieName: COOKIE_NAME, cookieValue: CONTENT_ID } + ); + + const trackingRequestPromise = testPage.waitForRequest( + request => + request.url().includes('cdn.builder.io/api/v1/track') && + request.method() === 'POST' && + request.postDataJSON().events[0].type === 'conversion', + { timeout: 10000 } + ); + + await testPage.goto('/track-conversion', { waitUntil: 'networkidle' }); + + // Click the basic conversion button + await testPage.click('text=Track conversion with amount'); + + const trackingRequest = await trackingRequestPromise; + const data = trackingRequest.postDataJSON(); + + expect(data.events).toHaveLength(1); + expect(data.events[0].type).toBe('conversion'); + expect(data.events[0].data.amount).toBe(100); + expect(data.events[0].data.contentId).toBe('test-content-id'); + }); + + test('should track basic conversion without amount', async ({ + page, + sdk, + baseURL, + packageName, + browser, + }) => { + test.skip(excludeGen1(sdk) || excludeRn(sdk)); + + const { page: testPage } = await initializeAbTest( + { + page, + baseURL, + packageName, + browser, + }, + { cookieName: COOKIE_NAME, cookieValue: CONTENT_ID } + ); + + const trackingRequestPromise = testPage.waitForRequest( + request => + request.url().includes('cdn.builder.io/api/v1/track') && + request.method() === 'POST' && + request.postDataJSON().events[0].type === 'conversion', + { timeout: 10000 } + ); + + await testPage.goto('/track-conversion', { waitUntil: 'networkidle' }); + + // Click the basic conversion button + await testPage.click('text=Track conversion without amount'); + + const trackingRequest = await trackingRequestPromise; + const data = trackingRequest.postDataJSON(); + + expect(data.events).toHaveLength(1); + expect(data.events[0].type).toBe('conversion'); + expect(data.events[0].data.amount).toBe(undefined); + expect(data.events[0].data.contentId).toBe('test-content-id'); + }); + + test('should track conversion with all parameters', async ({ + page, + sdk, + baseURL, + packageName, + browser, + }) => { + test.skip(excludeGen1(sdk) || excludeRn(sdk)); + + const { page: testPage } = await initializeAbTest( + { + page, + baseURL, + packageName, + browser, + }, + { cookieName: COOKIE_NAME, cookieValue: CONTENT_ID } + ); + + const trackingRequestPromise = testPage.waitForRequest( + request => + request.url().includes('cdn.builder.io/api/v1/track') && + request.method() === 'POST' && + request.postDataJSON().events[0].type === 'conversion', + { timeout: 10000 } + ); + + await testPage.goto('/track-conversion', { waitUntil: 'networkidle' }); + + // Click the full parameters conversion button + await testPage.click('text=Track Conversion with All Parameters'); + + const trackingRequest = await trackingRequestPromise; + const data = trackingRequest.postDataJSON(); + + expect(data.events).toHaveLength(1); + expect(data.events[0].type).toBe('conversion'); + expect(data.events[0].data.amount).toBe(100); + expect(data.events[0].data.contentId).toBe('test-content-id'); + expect(data.events[0].data.variationId).toBe('test-variation-id'); + expect(data.events[0].data.meta).toEqual({ + product: 'premium-shoes', + }); + expect(data.events[0].data.context).toEqual({ + userId: 'user-123', + }); + }); + }); + + test.describe('Conversion tracking with A/B tests', () => { + test('should track conversion with variation ID from cookie', async ({ + page, + browser, + packageName, + baseURL, + sdk, + }) => { + test.skip(excludeGen1(sdk) || excludeRn(sdk)); + + if (!baseURL) throw new Error('Missing baseURL'); + + // Create context with A/B test cookie + const { page: testPage } = await initializeAbTest( + { + page, + baseURL, + packageName, + browser, + }, + { cookieName: COOKIE_NAME, cookieValue: VARIANT_ID } + ); + + const trackingRequestPromise = testPage.waitForRequest( + request => + request.url().includes('cdn.builder.io/api/v1/track') && + request.method() === 'POST' && + request.postDataJSON().events[0].type === 'conversion', + { timeout: 10000 } + ); + + await testPage.goto('/track-conversion', { waitUntil: 'networkidle' }); + + // Click the basic conversion button + await testPage.click('text=Track Basic Conversion'); + + const trackingRequest = await trackingRequestPromise; + const data = trackingRequest.postDataJSON(); + + expect(data.events).toHaveLength(1); + expect(data.events[0].type).toBe('conversion'); + expect(data.events[0].data.amount).toBe(100); + expect(data.events[0].data.contentId).toBe('test-content-id'); + expect(data.events[0].data.variationId).toBe(VARIANT_ID); + + await testPage.context().close(); + }); + + test('should not set variationId when it equals contentId', async ({ + page, + baseURL, + sdk, + packageName, + browser, + }) => { + test.skip(excludeGen1(sdk) || excludeRn(sdk)); + + const { page: testPage } = await initializeAbTest( + { + page, + baseURL, + packageName, + browser, + }, + { cookieName: COOKIE_NAME, cookieValue: CONTENT_ID } + ); + + const trackingRequestPromise = testPage.waitForRequest( + request => + request.url().includes('cdn.builder.io/api/v1/track') && + request.method() === 'POST' && + request.postDataJSON().events[0].type === 'conversion', + { timeout: 10000 } + ); + + await testPage.goto('/track-conversion', { waitUntil: 'networkidle' }); + + // Click the basic conversion button + await testPage.click('text=Track Basic Conversion'); + + const trackingRequest = await trackingRequestPromise; + const data = trackingRequest.postDataJSON(); + + expect(data.events).toHaveLength(1); + expect(data.events[0].type).toBe('conversion'); + expect(data.events[0].data.amount).toBe(100); + expect(data.events[0].data.contentId).toBe('test-content-id'); + expect(data.events[0].data.variationId).toBeUndefined(); + expect(data.events[0].data.ownerId).toMatch(/abcd/); + + await testPage.context().close(); + }); + }); + + test.describe('Symbol Conversion Tracking', () => { + test('should track conversion from symbol', async ({ + page, + sdk, + packageName, + baseURL, + browser, + }) => { + test.skip(excludeGen1(sdk) || excludeRn(sdk)); + + // Skip packages that fetch symbol content on the server + const SSR_FETCHING_PACKAGES = ['nextjs-sdk-next-app', 'qwik-city']; + test.fail(SSR_FETCHING_PACKAGES.includes(packageName)); + + const { page: testPage } = await initializeAbTest( + { + page, + baseURL, + packageName, + browser, + }, + { cookieName: COOKIE_NAME, cookieValue: CONTENT_ID } + ); + + let symbolRequestCount = 0; + + await testPage.route(/.*cdn\.builder\.io\/api\/v3\/content\/symbol.*/, route => { + symbolRequestCount++; + return route.fulfill({ + status: 200, + json: { + results: [CONVERSION_SYMBOL_CONTENT], + }, + }); + }); + + const trackingRequestPromise = testPage.waitForRequest( + request => + request.url().includes('cdn.builder.io/api/v1/track') && + request.method() === 'POST' && + request.postDataJSON().events[0].type === 'conversion', + { timeout: 10000 } + ); + + await testPage.goto('/symbol-conversion', { waitUntil: 'networkidle' }); + + await testPage.click('text=Track Symbol Conversion'); + + const trackingRequest = await trackingRequestPromise; + const data = trackingRequest.postDataJSON(); + + expect(data.events).toHaveLength(1); + expect(data.events[0].type).toBe('conversion'); + expect(data.events[0].data.amount).toBeUndefined(); + expect(data.events[0].data.contentId).toBe('test-content-id'); + expect(data.events[0].data.ownerId).toMatch(/abcd/); + expect(data.events[0].data.sessionId).toMatch(/^[a-f0-9]{32}$/); + expect(data.events[0].data.visitorId).toMatch(/^[a-f0-9]{32}$/); + + expect(symbolRequestCount).toBeGreaterThanOrEqual(1); + + await testPage.context().close(); + }); + + test('should include correct headers when tracking from symbol', async ({ + page, + sdk, + packageName, + baseURL, + browser, + }) => { + test.skip(excludeGen1(sdk) || excludeRn(sdk)); + + // Skip packages that fetch symbol content on the server + const SSR_FETCHING_PACKAGES = ['nextjs-sdk-next-app', 'qwik-city']; + test.fail(SSR_FETCHING_PACKAGES.includes(packageName)); + + const { page: testPage } = await initializeAbTest( + { + page, + baseURL, + packageName, + browser, + }, + { cookieName: COOKIE_NAME, cookieValue: CONTENT_ID } + ); + + let symbolRequestCount = 0; + await testPage.route(/.*cdn\.builder\.io\/api\/v3\/content\/symbol.*/, route => { + symbolRequestCount++; + return route.fulfill({ + status: 200, + json: { + results: [CONVERSION_SYMBOL_CONTENT], + }, + }); + }); + + const trackingRequestPromise = testPage.waitForRequest( + request => + request.url().includes('cdn.builder.io/api/v1/track') && + request.method() === 'POST' && + request.postDataJSON().events[0].type === 'conversion', + { timeout: 10000 } + ); + + await testPage.goto('/symbol-conversion', { waitUntil: 'networkidle' }); + + // Click the symbol button + await testPage.click('text=Track Symbol Conversion'); + + const trackingRequest = await trackingRequestPromise; + const headers = trackingRequest.headers(); + + expect(headers['content-type']).toBe('application/json'); + expect(headers['x-builder-sdk']).toBeDefined(); + expect(headers['x-builder-sdk-gen']).toBeDefined(); + expect(headers['x-builder-sdk-version']).toMatch(/\d+\.\d+\.\d+/); + + expect(symbolRequestCount).toBeGreaterThanOrEqual(1); + + await testPage.context().close(); + }); + }); + + test.describe('Section Conversion Tracking', () => { + test('should track conversion from section button', async ({ + page, + sdk, + packageName, + baseURL, + browser, + }) => { + test.skip(excludeGen1(sdk) || excludeRn(sdk)); + + const { page: testPage } = await initializeAbTest( + { + page, + baseURL, + packageName, + browser, + }, + { cookieName: COOKIE_NAME, cookieValue: CONTENT_ID } + ); + + let symbolRequestCount = 0; + await testPage.route( + /.*cdn\.builder\.io\/api\/v3\/content\/sample-section-model.*/, + route => { + symbolRequestCount++; + return route.fulfill({ + status: 200, + json: { + results: [CONVERSION_SECTION_CONTENT], + }, + }); + } + ); + + const trackingRequestPromise = testPage.waitForRequest( + request => + request.url().includes('cdn.builder.io/api/v1/track') && + request.method() === 'POST' && + request.postDataJSON().events[0].type === 'conversion', + { timeout: 10000 } + ); + + await testPage.goto('/section-conversion', { waitUntil: 'networkidle' }); + + await testPage.pause(); + + await testPage.click('text=Section Button'); + + await testPage.pause(); + + const trackingRequest = await trackingRequestPromise; + const data = trackingRequest.postDataJSON(); + + expect(data.events).toHaveLength(1); + expect(data.events[0].type).toBe('conversion'); + expect(data.events[0].data.amount).toBeUndefined(); + expect(data.events[0].data.contentId).toBe('test-content-id'); + expect(data.events[0].data.ownerId).toMatch(/abcd/); + expect(data.events[0].data.sessionId).toMatch(/^[a-f0-9]{32}$/); + expect(data.events[0].data.visitorId).toMatch(/^[a-f0-9]{32}$/); + + expect(symbolRequestCount).toBeGreaterThanOrEqual(1); + + await testPage.context().close(); + }); + }); +}); diff --git a/packages/sdks-tests/src/specs/index.ts b/packages/sdks-tests/src/specs/index.ts index 4e29eedeb9e..bc892fd05e8 100644 --- a/packages/sdks-tests/src/specs/index.ts +++ b/packages/sdks-tests/src/specs/index.ts @@ -62,6 +62,7 @@ import { ACCORDION_WITH_NO_DETAIL, } from './accordion.js'; import { SYMBOL_TRACKING } from './symbol-tracking.js'; +import { TRACK_CONVERSION_CONTENT } from './track-conversion.js'; import { COLUMNS_WITH_DIFFERENT_WIDTHS } from './columns-with-different-widths.js'; import { CUSTOM_COMPONENTS_MODELS_RESTRICTION } from './custom-components-models.js'; import { EDITING_BOX_TO_COLUMN_INNER_LAYOUT } from './editing-columns-inner-layout.js'; @@ -98,6 +99,8 @@ import { SECTION_CHILDREN } from './section-children.js'; import { MAIN_CONTENT as SYMBOL_UPDATE_ENTRIES } from './get-content-symbol-update-entry.js'; import { HTTP_REQUESTS_POST_API_CONTENT } from './http-requests-post-api.js'; import { HTTP_REQUESTS_GET_API_CONTENT } from './http-requests-get-api.js'; +import { CONTENT_WITH_SECTION_MODEL } from './section-with-conversion.js'; +import { CONTENT_WITH_SYMBOL } from './symbol-with-conversion.js'; function isBrowser(): boolean { return typeof window !== 'undefined' && typeof document !== 'undefined'; @@ -209,6 +212,9 @@ export const PAGES: Record = { '/accordion-grid': { content: ACCORDION_GRID }, '/accordion-no-detail': { content: ACCORDION_WITH_NO_DETAIL }, '/symbol-tracking': { content: SYMBOL_TRACKING }, + '/track-conversion': { content: TRACK_CONVERSION_CONTENT }, + '/symbol-conversion': { content: CONTENT_WITH_SYMBOL }, + '/section-conversion': { content: CONTENT_WITH_SECTION_MODEL }, '/columns-with-different-widths': { content: COLUMNS_WITH_DIFFERENT_WIDTHS }, '/custom-components-no-default-value': { content: CUSTOM_COMPONENT_NO_DEFAULT_VALUE }, '/custom-components-models-show': { diff --git a/packages/sdks-tests/src/specs/section-with-conversion.ts b/packages/sdks-tests/src/specs/section-with-conversion.ts new file mode 100644 index 00000000000..18e0cac1b12 --- /dev/null +++ b/packages/sdks-tests/src/specs/section-with-conversion.ts @@ -0,0 +1,169 @@ +import type { BuilderContent } from './types.js'; + +export const CONVERSION_SECTION_CONTENT: BuilderContent = { + id: 'section-conversion-test-id', + data: { + title: 'Section Conversion Test', + inputs: [], + blocks: [ + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + id: 'builder-cb4f82738ca74cfea35bf6b4e17aeb4b', + component: { + name: 'Core:Section', + options: { + maxWidth: 1200, + lazyLoad: false, + }, + isRSC: null, + }, + children: [ + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + actions: { + click: 'builder.trackConversion()', + }, + code: { + actions: { + click: 'builder.trackConversion();\n', + }, + }, + id: 'builder-e5427a3b502d40c3a29de1e626fd1618', + meta: { + eventActions: { + '': [], + click: [ + { + '@type': '@builder.io/core:Action', + action: '@builder.io:trackEvent', + options: { + name: 'myEvent', + trackConversion: true, + }, + }, + ], + }, + }, + component: { + name: 'Core:Button', + options: { + text: 'Section Button', + openLinkInNewTab: false, + }, + isRSC: null, + }, + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '20px', + appearance: 'none', + paddingTop: '15px', + paddingBottom: '15px', + paddingLeft: '25px', + paddingRight: '25px', + backgroundColor: 'black', + color: 'white', + borderRadius: '4px', + textAlign: 'center', + cursor: 'pointer', + }, + }, + }, + ], + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '0px', + paddingLeft: '20px', + paddingRight: '20px', + paddingTop: '20px', + paddingBottom: '20px', + minHeight: '100px', + }, + }, + }, + ], + }, + meta: { + hasLinks: false, + kind: 'page', + }, +}; + +export const CONTENT_WITH_SECTION_MODEL = { + id: 'test-content-id', + data: { + title: 'Symbol Conversion Test', + inputs: [], + blocks: [ + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + id: 'builder-text-header', + component: { + name: 'Text', + options: { + text: '

Symbol Conversion Tracking Test

', + }, + }, + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '20px', + lineHeight: 'normal', + height: 'auto', + textAlign: 'center', + }, + }, + }, + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + id: 'builder-f2a4c0079d5149c3bc23e3c3032f88df', + component: { + name: 'Symbol', + options: { + dataOnly: false, + inheritState: false, + renderToLiquid: false, + symbol: { + model: 'sample-section-model', + data: {}, + entry: 'section-conversion-test-id', + ownerId: '75c6e293e39b4890ac75a37bbca0a447', + }, + }, + isRSC: true, + }, + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '20px', + }, + }, + }, + ], + }, + meta: { + hasLinks: false, + kind: 'page', + }, +}; diff --git a/packages/sdks-tests/src/specs/symbol-with-conversion.ts b/packages/sdks-tests/src/specs/symbol-with-conversion.ts new file mode 100644 index 00000000000..f5d1d610dbd --- /dev/null +++ b/packages/sdks-tests/src/specs/symbol-with-conversion.ts @@ -0,0 +1,119 @@ +import type { BuilderContent } from './types.js'; + +export const CONVERSION_SYMBOL_CONTENT = { + createdDate: Date.now(), + createdBy: 'test-user', + variations: {}, + name: 'conversion tracking symbol', + published: 'published', + firstPublished: Date.now(), + testRatio: 1, + data: { + blocks: [ + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + id: 'builder-conversion-symbol-button', + actions: { + click: 'console.log(builder);builder.trackConversion();\n', + }, + code: { + actions: { + click: 'console.log(builder);builder.trackConversion();\n', + }, + }, + component: { + name: 'Core:Button', + options: { + text: 'Track Symbol Conversion', + openLinkInNewTab: false, + }, + }, + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '20px', + appearance: 'none', + paddingTop: '15px', + paddingBottom: '15px', + paddingLeft: '25px', + paddingRight: '25px', + backgroundColor: 'blue', + color: 'white', + borderRadius: '4px', + textAlign: 'center', + cursor: 'pointer', + }, + }, + }, + ], + }, + id: 'conversion-symbol-id', + rev: 'test-rev', +} as const; + +export const CONTENT_WITH_SYMBOL: BuilderContent = { + id: 'test-content-id', + data: { + title: 'Symbol Conversion Test', + inputs: [], + blocks: [ + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + id: 'builder-text-header', + component: { + name: 'Text', + options: { + text: '

Symbol Conversion Tracking Test

', + }, + }, + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '20px', + lineHeight: 'normal', + height: 'auto', + textAlign: 'center', + }, + }, + }, + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + id: 'builder-symbol-with-conversion', + component: { + name: 'Symbol', + options: { + symbol: { + model: 'symbol', + entry: 'conversion-symbol-id', + }, + }, + }, + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '20px', + }, + }, + }, + ], + }, + meta: { + hasLinks: false, + kind: 'page', + }, +}; diff --git a/packages/sdks-tests/src/specs/track-conversion.ts b/packages/sdks-tests/src/specs/track-conversion.ts new file mode 100644 index 00000000000..ad94f71f99e --- /dev/null +++ b/packages/sdks-tests/src/specs/track-conversion.ts @@ -0,0 +1,554 @@ +export const TRACK_CONVERSION_CONTENT = { + published: 'published', + lastUpdatedBy: 'otrVluNzvNScbfBcM1pwuzPvX1o1', + modelId: '8b9bea507dd04be3a23d84c5d3824a48', + data: { + themeId: false, + inputs: [], + title: '[TEST] Insights Test', + blocks: [ + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + id: 'builder-5e19c8bf75d243f2807ed6194bb1cfb3', + children: [ + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + actions: { + click: 'builder.trackConversion(100);\n', + }, + code: { + actions: { + click: 'builder.trackConversion(100);\n', + }, + }, + id: 'builder-92b45ec0dbab4c4ba6fbe6d9f2557bff', + meta: { + eventActions: { + click: [ + { + '@type': '@builder.io/core:Action', + action: '@builder.io:trackEvent', + options: { + name: 'myEvent', + amount: 100, + trackConversion: true, + }, + }, + ], + }, + }, + component: { + name: 'Core:Button', + options: { + text: 'Track Basic Conversion', + openLinkInNewTab: false, + }, + isRSC: null, + }, + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '20px', + appearance: 'none', + paddingTop: '15px', + paddingBottom: '15px', + paddingLeft: '25px', + paddingRight: '25px', + backgroundColor: 'black', + color: 'white', + borderRadius: '4px', + textAlign: 'center', + cursor: 'pointer', + }, + }, + }, + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + id: 'builder-eaeaec2f9fcd435993dbf28ba5060c6c', + component: { + name: 'Text', + options: { + text: '

Default Variant

', + }, + isRSC: null, + }, + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '150px', + lineHeight: 'normal', + height: 'auto', + marginBottom: '10px', + paddingBottom: '0px', + }, + }, + }, + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + actions: { + click: 'builder.trackConversion();\n', + }, + code: { + actions: { + click: 'builder.trackConversion();\n', + }, + }, + id: 'builder-3b4d00a8858c4ebc951bf0860543dc67', + meta: { + eventActions: { + click: [ + { + '@type': '@builder.io/core:Action', + action: '@builder.io:trackEvent', + options: { + name: 'myEvent', + trackConversion: true, + }, + }, + ], + }, + }, + component: { + name: 'Core:Button', + options: { + text: 'Track conversion without amount', + openLinkInNewTab: false, + }, + isRSC: null, + }, + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '20px', + appearance: 'none', + paddingTop: '15px', + paddingBottom: '15px', + paddingLeft: '25px', + paddingRight: '25px', + backgroundColor: 'black', + color: 'white', + borderRadius: '4px', + textAlign: 'center', + cursor: 'pointer', + }, + }, + }, + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + actions: { + click: 'builder.trackConversion(100);\n', + }, + code: { + actions: { + click: 'builder.trackConversion(100);\n', + }, + }, + id: 'builder-3b4d00a8858c4ebc951bf0860543dc67', + meta: { + eventActions: { + '': [], + click: [ + { + '@type': '@builder.io/core:Action', + action: '@builder.io:trackEvent', + options: { + name: 'myEvent', + trackConversion: true, + }, + }, + ], + }, + }, + component: { + name: 'Core:Button', + options: { + text: 'Track conversion with amount', + openLinkInNewTab: false, + }, + isRSC: null, + }, + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '20px', + appearance: 'none', + paddingTop: '15px', + paddingBottom: '15px', + paddingLeft: '25px', + paddingRight: '25px', + backgroundColor: 'black', + color: 'white', + borderRadius: '4px', + textAlign: 'center', + cursor: 'pointer', + }, + }, + }, + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + actions: { + click: + "builder.trackConversion(100, 'test-content-id', 'test-variation-id', { product: 'premium-shoes' }, { userId: 'user-123' });\n", + }, + code: { + actions: { + click: + "builder.trackConversion(100, 'test-content-id', 'test-variation-id', { product: 'premium-shoes' }, { userId: 'user-123' });\n", + }, + }, + id: 'builder-3b4d00a8858c4ebc951bf0860543dc67', + meta: { + eventActions: { + '': [], + click: [ + { + '@type': '@builder.io/core:Action', + action: '@builder.io:trackEvent', + options: { + name: 'myEvent', + trackConversion: true, + }, + }, + ], + }, + }, + component: { + name: 'Core:Button', + options: { + text: 'Track Conversion with All Parameters', + openLinkInNewTab: false, + }, + isRSC: null, + }, + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '20px', + appearance: 'none', + paddingTop: '15px', + paddingBottom: '15px', + paddingLeft: '25px', + paddingRight: '25px', + backgroundColor: 'black', + color: 'white', + borderRadius: '4px', + textAlign: 'center', + cursor: 'pointer', + }, + }, + }, + ], + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '20px', + height: 'auto', + paddingBottom: '0px', + marginRight: '0px', + justifyContent: 'center', + width: 'auto', + alignSelf: 'center', + flexGrow: '0', + }, + }, + }, + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + id: 'builder-section-with-conversion-test', + component: { + name: 'Core:Section', + options: { + maxWidth: 1200, + lazyLoad: false, + }, + isRSC: null, + }, + children: [ + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + actions: { + click: 'builder.trackConversion()', + }, + code: { + actions: { + click: 'builder.trackConversion();\n', + }, + }, + id: 'builder-section-button-conversion', + meta: { + eventActions: { + '': [], + click: [ + { + '@type': '@builder.io/core:Action', + action: '@builder.io:trackEvent', + options: { + name: 'myEvent', + trackConversion: true, + }, + }, + ], + }, + }, + component: { + name: 'Core:Button', + options: { + text: 'Section Button Conversion', + openLinkInNewTab: false, + }, + isRSC: null, + }, + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '20px', + appearance: 'none', + paddingTop: '15px', + paddingBottom: '15px', + paddingLeft: '25px', + paddingRight: '25px', + backgroundColor: 'black', + color: 'white', + borderRadius: '4px', + textAlign: 'center', + cursor: 'pointer', + }, + }, + }, + ], + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '20px', + paddingLeft: '20px', + paddingRight: '20px', + paddingTop: '20px', + paddingBottom: '20px', + minHeight: '100px', + }, + }, + }, + { + id: 'builder-pixel-11upajbeli8i', + '@type': '@builder.io/sdk:Element', + tagName: 'img', + properties: { + src: 'https://cdn.builder.io/api/v1/pixel?apiKey=75c6e293e39b4890ac75a37bbca0a447', + 'aria-hidden': 'true', + alt: '', + role: 'presentation', + width: '0', + height: '0', + }, + responsiveStyles: { + large: { + height: '0', + width: '0', + display: 'inline-block', + opacity: '0', + overflow: 'hidden', + pointerEvents: 'none', + }, + }, + }, + ], + url: '/track-conversion', + state: { + deviceSize: 'large', + location: { + path: '', + query: {}, + }, + }, + }, + createdBy: 'otrVluNzvNScbfBcM1pwuzPvX1o1', + firstPublished: 1758694322547, + meta: { + hasLinks: false, + kind: 'page', + }, + testRatio: 0.5, + folders: [], + name: '[TEST] Insights Test', + query: [ + { + '@type': '@builder.io/core:Query', + property: 'urlPath', + value: '/track-conversion', + operator: 'is', + }, + ], + createdDate: 1758694019198, + variations: { + 'test-variation-id': { + createdDate: 1758694157902, + testRatio: 0.5, + meta: {}, + name: 'Variation 1', + data: { + title: '[TEST] Insights Test', + inputs: [], + themeId: false, + blocks: [ + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + id: 'builder-7b0c38da24fa4cf98d6d1e6a5b277a58', + meta: { + previousId: 'builder-5e19c8bf75d243f2807ed6194bb1cfb3', + }, + children: [ + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + id: 'builder-e0dab9fa9dc6487baa20c9bb176417aa', + meta: { + previousId: 'builder-eaeaec2f9fcd435993dbf28ba5060c6c', + }, + component: { + name: 'Text', + options: { + text: '

Variation 1

', + }, + isRSC: null, + }, + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '150px', + lineHeight: 'normal', + height: 'auto', + marginBottom: '1px', + marginLeft: 'auto', + marginRight: 'auto', + }, + }, + }, + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + actions: { + click: 'builder.trackConversion(100);\n', + }, + code: { + actions: { + click: 'builder.trackConversion(100);\n', + }, + }, + id: 'builder-92b45ec0dbab4c4ba6fbe6d9f2557bff', + meta: { + eventActions: { + click: [ + { + '@type': '@builder.io/core:Action', + action: '@builder.io:trackEvent', + options: { + name: 'myEvent', + amount: 100, + trackConversion: true, + }, + }, + ], + }, + }, + component: { + name: 'Core:Button', + options: { + text: 'Track Basic Conversion', + openLinkInNewTab: false, + }, + isRSC: null, + }, + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '20px', + appearance: 'none', + paddingTop: '15px', + paddingBottom: '15px', + paddingLeft: '25px', + paddingRight: '25px', + backgroundColor: 'black', + color: 'white', + borderRadius: '4px', + textAlign: 'center', + cursor: 'pointer', + }, + }, + }, + ], + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '20px', + height: 'auto', + paddingBottom: '0px', + marginRight: '0px', + justifyContent: 'center', + width: 'auto', + alignSelf: 'center', + flexGrow: '0', + }, + }, + }, + ], + }, + id: 'bf7b530224a447c98ae635136ca1128d', + }, + }, + lastUpdated: 1758814072829, + id: 'test-content-id', + rev: 'lj38917wpgd', +}; diff --git a/packages/sdks/src/components/content/content.lite.tsx b/packages/sdks/src/components/content/content.lite.tsx index 6d291a30547..c3df6d97be7 100644 --- a/packages/sdks/src/components/content/content.lite.tsx +++ b/packages/sdks/src/components/content/content.lite.tsx @@ -141,12 +141,13 @@ export default function ContentComponent(props: ContentProps) { 'No API key provided to `Content` component. This can cause issues. Please provide an API key using the `apiKey` prop.' ); } - - setGlobalBuilderContext({ - apiKey: props.apiKey, - apiHost: props.apiHost, - contentId: builderContextSignal.value.content?.id, - }); + if (!props.isNestedRender) { + setGlobalBuilderContext({ + apiKey: props.apiKey, + apiHost: props.apiHost, + contentId: props.content?.id, + }); + } if (isBrowser()) { setTestsFromUrl(); diff --git a/packages/sdks/src/functions/evaluate/helpers.test.ts b/packages/sdks/src/functions/evaluate/helpers.test.ts index 8dacabb604f..625eaf3f6d6 100644 --- a/packages/sdks/src/functions/evaluate/helpers.test.ts +++ b/packages/sdks/src/functions/evaluate/helpers.test.ts @@ -1,4 +1,261 @@ -import { flattenState } from './helpers'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getDefaultCanTrack } from '../../helpers/canTrack.js'; +import { getTestCookie } from '../content-variants.js'; +import { getGlobalBuilderContext } from '../global-context.js'; +import { _track } from '../track/index.js'; +import { flattenState, getBuilderGlobals } from './helpers'; + +// Mock dependencies +vi.mock('../track/index.js', () => ({ + _track: vi.fn(), +})); + +vi.mock('../global-context.js', () => ({ + getGlobalBuilderContext: vi.fn(), +})); + +vi.mock('../content-variants.js', () => ({ + getTestCookie: vi.fn(), +})); + +vi.mock('../../helpers/canTrack.js', () => ({ + getDefaultCanTrack: vi.fn(), +})); + +describe('getBuilderGlobals', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getGlobalBuilderContext).mockReturnValue({}); + vi.mocked(getDefaultCanTrack).mockReturnValue(true); + vi.mocked(getTestCookie).mockReturnValue(undefined); + }); + + describe('track function', () => { + it('should call _track with correct parameters', () => { + const builderGlobals = getBuilderGlobals(); + const mockContext = { + apiHost: 'https://test.builder.io', + apiKey: 'test-api-key', + }; + + vi.mocked(getGlobalBuilderContext).mockReturnValue(mockContext); + vi.mocked(getDefaultCanTrack).mockReturnValue(true); + + builderGlobals.track('click', { customProp: 'value' }, { userId: '123' }); + + expect(_track).toHaveBeenCalledWith({ + type: 'click', + customProp: 'value', + apiHost: 'https://test.builder.io', + apiKey: 'test-api-key', + context: { userId: '123' }, + canTrack: true, + }); + }); + + it('should use empty string for apiKey when not provided', () => { + const builderGlobals = getBuilderGlobals(); + vi.mocked(getGlobalBuilderContext).mockReturnValue({ + apiHost: 'https://test.builder.io', + }); + + builderGlobals.track('pageview', {}); + + expect(_track).toHaveBeenCalledWith({ + type: 'pageview', + apiHost: 'https://test.builder.io', + apiKey: '', + context: undefined, + canTrack: true, + }); + }); + + it('should work with no properties provided', () => { + const builderGlobals = getBuilderGlobals(); + + builderGlobals.track('custom-event', {}); + + expect(_track).toHaveBeenCalledWith({ + type: 'custom-event', + apiHost: undefined, + apiKey: '', + context: undefined, + canTrack: true, + }); + }); + }); + + describe('trackConversion function', () => { + it('should call _track with conversion type and all parameters', () => { + const builderGlobals = getBuilderGlobals(); + const mockContext = { + apiHost: 'https://test.builder.io', + apiKey: 'test-api-key', + contentId: 'content-123', + }; + + vi.mocked(getGlobalBuilderContext).mockReturnValue(mockContext); + vi.mocked(getDefaultCanTrack).mockReturnValue(true); + + builderGlobals.trackConversion( + 100, + 'content-456', + 'variation-789', + { product: 'shoes' }, + { userId: '123' } + ); + + expect(_track).toHaveBeenCalledWith({ + type: 'conversion', + apiHost: 'https://test.builder.io', + apiKey: 'test-api-key', + amount: 100, + contentId: 'content-456', + variationId: 'variation-789', + meta: { product: 'shoes' }, + context: { userId: '123' }, + canTrack: true, + }); + }); + + it('should use contentId from global context when not provided', () => { + const builderGlobals = getBuilderGlobals(); + const mockContext = { + apiKey: 'test-api-key', + contentId: 'global-content-123', + }; + + vi.mocked(getGlobalBuilderContext).mockReturnValue(mockContext); + + builderGlobals.trackConversion(50); + + expect(_track).toHaveBeenCalledWith({ + type: 'conversion', + apiHost: undefined, + apiKey: 'test-api-key', + amount: 50, + contentId: 'global-content-123', + variationId: undefined, + meta: undefined, + context: undefined, + canTrack: true, + }); + }); + + it('should handle contentId as object (legacy format)', () => { + const builderGlobals = getBuilderGlobals(); + const metaObject = { product: 'shoes', category: 'footwear' }; + + vi.mocked(getGlobalBuilderContext).mockReturnValue({ + apiKey: 'test-key', + }); + + builderGlobals.trackConversion(75, metaObject, 'variation-123'); + + expect(_track).toHaveBeenCalledWith({ + type: 'conversion', + apiHost: undefined, + apiKey: 'test-key', + amount: 75, + contentId: undefined, + variationId: undefined, // variationId is undefined because contentId is undefined + meta: metaObject, + context: undefined, + canTrack: true, + }); + }); + + it('should get variationId from test cookie when not provided', () => { + const builderGlobals = getBuilderGlobals(); + + vi.mocked(getGlobalBuilderContext).mockReturnValue({ + apiKey: 'test-key', + }); + vi.mocked(getTestCookie).mockReturnValue('cookie-variation-456'); + + builderGlobals.trackConversion(25, 'content-789'); + + expect(getTestCookie).toHaveBeenCalledWith('content-789'); + expect(_track).toHaveBeenCalledWith({ + type: 'conversion', + apiHost: undefined, + apiKey: 'test-key', + amount: 25, + contentId: 'content-789', + variationId: 'cookie-variation-456', + meta: undefined, + context: undefined, + canTrack: true, + }); + }); + + it('should not set variationId when it equals contentId', () => { + const builderGlobals = getBuilderGlobals(); + + vi.mocked(getGlobalBuilderContext).mockReturnValue({ + apiKey: 'test-key', + }); + + builderGlobals.trackConversion(30, 'content-123', 'content-123'); + + expect(_track).toHaveBeenCalledWith({ + type: 'conversion', + apiHost: undefined, + apiKey: 'test-key', + amount: 30, + contentId: 'content-123', + variationId: undefined, + meta: undefined, + context: undefined, + canTrack: true, + }); + }); + + it('should handle all parameters as undefined', () => { + const builderGlobals = getBuilderGlobals(); + + vi.mocked(getGlobalBuilderContext).mockReturnValue({}); + + builderGlobals.trackConversion(); + + expect(_track).toHaveBeenCalledWith({ + type: 'conversion', + apiHost: undefined, + apiKey: '', + amount: undefined, + contentId: undefined, + variationId: undefined, + meta: undefined, + context: undefined, + canTrack: true, + }); + }); + + it('should prioritize provided contentId over global context', () => { + const builderGlobals = getBuilderGlobals(); + const mockContext = { + apiKey: 'test-key', + contentId: 'global-content-123', + }; + + vi.mocked(getGlobalBuilderContext).mockReturnValue(mockContext); + + builderGlobals.trackConversion(100, 'explicit-content-456'); + + expect(_track).toHaveBeenCalledWith({ + type: 'conversion', + apiHost: undefined, + apiKey: 'test-key', + amount: 100, + contentId: 'explicit-content-456', + variationId: undefined, + meta: undefined, + context: undefined, + canTrack: true, + }); + }); + }); +}); describe('flatten state', () => { it('should behave normally when no PROTO_STATE', () => { From 2bacf49ab408b480149afcd6fb9ab1abb2d8f4dc Mon Sep 17 00:00:00 2001 From: Midhun A Darvin Date: Mon, 29 Sep 2025 22:15:26 +0530 Subject: [PATCH 08/14] fix: test --- packages/sdks-tests/src/specs/track-conversion.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdks-tests/src/specs/track-conversion.ts b/packages/sdks-tests/src/specs/track-conversion.ts index ad94f71f99e..f8d7fb74a30 100644 --- a/packages/sdks-tests/src/specs/track-conversion.ts +++ b/packages/sdks-tests/src/specs/track-conversion.ts @@ -545,7 +545,7 @@ export const TRACK_CONVERSION_CONTENT = { }, ], }, - id: 'bf7b530224a447c98ae635136ca1128d', + id: 'test-variation-id', }, }, lastUpdated: 1758814072829, From c018f6f3efd2c346190c27417491a33547ec6df7 Mon Sep 17 00:00:00 2001 From: Midhun A Darvin Date: Tue, 30 Sep 2025 10:42:32 +0530 Subject: [PATCH 09/14] fix: add tests --- .../sdks-tests/src/e2e-tests/ab-test.spec.ts | 4 +- .../src/e2e-tests/track-conversion.spec.ts | 51 +++---- .../src/specs/section-with-conversion.ts | 10 +- .../src/specs/symbol-with-conversion.ts | 4 +- .../sdks-tests/src/specs/track-conversion.ts | 90 +----------- .../personalization-container/helpers.ts | 6 + .../components/content-variants/helpers.ts | 4 + .../content-variants/inlined-fns.ts | 136 ++++++++++++++++++ .../src/components/content/content.lite.tsx | 30 ++-- .../src/functions/evaluate/helpers.test.ts | 68 ++++++--- .../sdks/src/functions/evaluate/helpers.ts | 9 +- packages/sdks/src/functions/global-context.ts | 94 ------------ 12 files changed, 259 insertions(+), 247 deletions(-) delete mode 100644 packages/sdks/src/functions/global-context.ts diff --git a/packages/sdks-tests/src/e2e-tests/ab-test.spec.ts b/packages/sdks-tests/src/e2e-tests/ab-test.spec.ts index 3886cce944a..179e961acc7 100644 --- a/packages/sdks-tests/src/e2e-tests/ab-test.spec.ts +++ b/packages/sdks-tests/src/e2e-tests/ab-test.spec.ts @@ -10,7 +10,7 @@ import { CONTENT as AB_TEST_CONTENT } from '../specs/ab-test.js'; const SELECTOR = 'div[builder-content-id]'; -const createContextWithCookies = async ({ +export const createContextWithCookies = async ({ cookies, baseURL, browser, @@ -37,7 +37,7 @@ const createContextWithCookies = async ({ return context; }; -const initializeAbTest = async ( +export const initializeAbTest = async ( { page: _page, baseURL, diff --git a/packages/sdks-tests/src/e2e-tests/track-conversion.spec.ts b/packages/sdks-tests/src/e2e-tests/track-conversion.spec.ts index 10169d2be1d..89803063a10 100644 --- a/packages/sdks-tests/src/e2e-tests/track-conversion.spec.ts +++ b/packages/sdks-tests/src/e2e-tests/track-conversion.spec.ts @@ -4,11 +4,7 @@ import { excludeGen1, excludeRn, test } from '../helpers/index.js'; import { CONVERSION_SYMBOL_CONTENT } from '../specs/symbol-with-conversion.js'; import { CONVERSION_SECTION_CONTENT } from '../specs/section-with-conversion.js'; -const COOKIE_NAME = 'builder.tests.test-content-id'; -const CONTENT_ID = 'test-content-id'; -const VARIANT_ID = 'test-variation-id'; - -const createContextWithCookies = async ({ +export const createContextWithCookies = async ({ cookies, baseURL, browser, @@ -35,7 +31,7 @@ const createContextWithCookies = async ({ return context; }; -const initializeAbTest = async ( +export const initializeAbTest = async ( { page: _page, baseURL, @@ -68,14 +64,12 @@ const initializeAbTest = async ( return { page }; }; +const COOKIE_NAME = 'builder.tests.test-content-id'; +const CONTENT_ID = 'test-content-id'; +const VARIANT_ID = 'test-variation-id'; + test.describe('Track Conversion', () => { test.describe('Basic conversion tracking', () => { - // clear cookies before each test - test.beforeEach(async ({ page }) => { - await page.context().clearCookies(); - await page.reload(); - }); - test('should track basic conversion with amount', async ({ page, sdk, @@ -241,7 +235,7 @@ test.describe('Track Conversion', () => { await testPage.goto('/track-conversion', { waitUntil: 'networkidle' }); // Click the basic conversion button - await testPage.click('text=Track Basic Conversion'); + await testPage.click('text=Track Basic Conversion - Variant'); const trackingRequest = await trackingRequestPromise; const data = trackingRequest.postDataJSON(); @@ -285,7 +279,7 @@ test.describe('Track Conversion', () => { await testPage.goto('/track-conversion', { waitUntil: 'networkidle' }); // Click the basic conversion button - await testPage.click('text=Track Basic Conversion'); + await testPage.click('text=Track Basic Conversion - Default'); const trackingRequest = await trackingRequestPromise; const data = trackingRequest.postDataJSON(); @@ -310,10 +304,12 @@ test.describe('Track Conversion', () => { browser, }) => { test.skip(excludeGen1(sdk) || excludeRn(sdk)); - - // Skip packages that fetch symbol content on the server - const SSR_FETCHING_PACKAGES = ['nextjs-sdk-next-app', 'qwik-city']; - test.fail(SSR_FETCHING_PACKAGES.includes(packageName)); + test.skip( + packageName === 'nextjs-sdk-next-app' || + packageName === 'gen1-next14-pages' || + packageName === 'gen1-next15-app' || + packageName === 'gen1-remix' + ); const { page: testPage } = await initializeAbTest( { @@ -373,10 +369,13 @@ test.describe('Track Conversion', () => { browser, }) => { test.skip(excludeGen1(sdk) || excludeRn(sdk)); - // Skip packages that fetch symbol content on the server - const SSR_FETCHING_PACKAGES = ['nextjs-sdk-next-app', 'qwik-city']; - test.fail(SSR_FETCHING_PACKAGES.includes(packageName)); + test.skip( + packageName === 'nextjs-sdk-next-app' || + packageName === 'gen1-next14-pages' || + packageName === 'gen1-next15-app' || + packageName === 'gen1-remix' + ); const { page: testPage } = await initializeAbTest( { @@ -435,6 +434,12 @@ test.describe('Track Conversion', () => { browser, }) => { test.skip(excludeGen1(sdk) || excludeRn(sdk)); + test.skip( + packageName === 'nextjs-sdk-next-app' || + packageName === 'gen1-next14-pages' || + packageName === 'gen1-next15-app' || + packageName === 'gen1-remix' + ); const { page: testPage } = await initializeAbTest( { @@ -470,12 +475,8 @@ test.describe('Track Conversion', () => { await testPage.goto('/section-conversion', { waitUntil: 'networkidle' }); - await testPage.pause(); - await testPage.click('text=Section Button'); - await testPage.pause(); - const trackingRequest = await trackingRequestPromise; const data = trackingRequest.postDataJSON(); diff --git a/packages/sdks-tests/src/specs/section-with-conversion.ts b/packages/sdks-tests/src/specs/section-with-conversion.ts index 18e0cac1b12..1caae1a1385 100644 --- a/packages/sdks-tests/src/specs/section-with-conversion.ts +++ b/packages/sdks-tests/src/specs/section-with-conversion.ts @@ -103,7 +103,7 @@ export const CONVERSION_SECTION_CONTENT: BuilderContent = { export const CONTENT_WITH_SECTION_MODEL = { id: 'test-content-id', data: { - title: 'Symbol Conversion Test', + title: 'Section Conversion Test', inputs: [], blocks: [ { @@ -113,7 +113,7 @@ export const CONTENT_WITH_SECTION_MODEL = { component: { name: 'Text', options: { - text: '

Symbol Conversion Tracking Test

', + text: '

Section Conversion Tracking Test

', }, }, responsiveStyles: { @@ -137,17 +137,11 @@ export const CONTENT_WITH_SECTION_MODEL = { component: { name: 'Symbol', options: { - dataOnly: false, - inheritState: false, - renderToLiquid: false, symbol: { model: 'sample-section-model', - data: {}, entry: 'section-conversion-test-id', - ownerId: '75c6e293e39b4890ac75a37bbca0a447', }, }, - isRSC: true, }, responsiveStyles: { large: { diff --git a/packages/sdks-tests/src/specs/symbol-with-conversion.ts b/packages/sdks-tests/src/specs/symbol-with-conversion.ts index f5d1d610dbd..119dfaf2c00 100644 --- a/packages/sdks-tests/src/specs/symbol-with-conversion.ts +++ b/packages/sdks-tests/src/specs/symbol-with-conversion.ts @@ -15,11 +15,11 @@ export const CONVERSION_SYMBOL_CONTENT = { '@version': 2, id: 'builder-conversion-symbol-button', actions: { - click: 'console.log(builder);builder.trackConversion();\n', + click: 'builder.trackConversion();\n', }, code: { actions: { - click: 'console.log(builder);builder.trackConversion();\n', + click: 'builder.trackConversion();\n', }, }, component: { diff --git a/packages/sdks-tests/src/specs/track-conversion.ts b/packages/sdks-tests/src/specs/track-conversion.ts index f8d7fb74a30..6fcfb102e45 100644 --- a/packages/sdks-tests/src/specs/track-conversion.ts +++ b/packages/sdks-tests/src/specs/track-conversion.ts @@ -42,7 +42,7 @@ export const TRACK_CONVERSION_CONTENT = { component: { name: 'Core:Button', options: { - text: 'Track Basic Conversion', + text: 'Track Basic Conversion - Default', openLinkInNewTab: false, }, isRSC: null, @@ -282,92 +282,6 @@ export const TRACK_CONVERSION_CONTENT = { }, }, }, - { - '@type': '@builder.io/sdk:Element', - '@version': 2, - id: 'builder-section-with-conversion-test', - component: { - name: 'Core:Section', - options: { - maxWidth: 1200, - lazyLoad: false, - }, - isRSC: null, - }, - children: [ - { - '@type': '@builder.io/sdk:Element', - '@version': 2, - actions: { - click: 'builder.trackConversion()', - }, - code: { - actions: { - click: 'builder.trackConversion();\n', - }, - }, - id: 'builder-section-button-conversion', - meta: { - eventActions: { - '': [], - click: [ - { - '@type': '@builder.io/core:Action', - action: '@builder.io:trackEvent', - options: { - name: 'myEvent', - trackConversion: true, - }, - }, - ], - }, - }, - component: { - name: 'Core:Button', - options: { - text: 'Section Button Conversion', - openLinkInNewTab: false, - }, - isRSC: null, - }, - responsiveStyles: { - large: { - display: 'flex', - flexDirection: 'column', - position: 'relative', - flexShrink: '0', - boxSizing: 'border-box', - marginTop: '20px', - appearance: 'none', - paddingTop: '15px', - paddingBottom: '15px', - paddingLeft: '25px', - paddingRight: '25px', - backgroundColor: 'black', - color: 'white', - borderRadius: '4px', - textAlign: 'center', - cursor: 'pointer', - }, - }, - }, - ], - responsiveStyles: { - large: { - display: 'flex', - flexDirection: 'column', - position: 'relative', - flexShrink: '0', - boxSizing: 'border-box', - marginTop: '20px', - paddingLeft: '20px', - paddingRight: '20px', - paddingTop: '20px', - paddingBottom: '20px', - minHeight: '100px', - }, - }, - }, { id: 'builder-pixel-11upajbeli8i', '@type': '@builder.io/sdk:Element', @@ -498,7 +412,7 @@ export const TRACK_CONVERSION_CONTENT = { component: { name: 'Core:Button', options: { - text: 'Track Basic Conversion', + text: 'Track Basic Conversion - Variant', openLinkInNewTab: false, }, isRSC: null, diff --git a/packages/sdks/src/blocks/personalization-container/helpers.ts b/packages/sdks/src/blocks/personalization-container/helpers.ts index c3e6d27c229..6acae4d5068 100644 --- a/packages/sdks/src/blocks/personalization-container/helpers.ts +++ b/packages/sdks/src/blocks/personalization-container/helpers.ts @@ -15,6 +15,8 @@ export const DEFAULT_INDEX = 'default'; const FILTER_WITH_CUSTOM_TARGETING_SCRIPT_FN_NAME = 'filterWithCustomTargeting'; const BUILDER_IO_PERSONALIZATION_SCRIPT_FN_NAME = 'builderIoPersonalization'; const UPDATE_VARIANT_VISIBILITY_SCRIPT_FN_NAME = 'updateVisibilityStylesScript'; +const SETUP_GLOBAL_BUILDER_CONTEXT_SCRIPT_FN_NAME = + 'builderIoInitializeGlobalBuilderContext'; export type UserAttributes = { date?: string | Date; @@ -173,4 +175,8 @@ export const getUpdateVisibilityStylesScript = ( return `window.${UPDATE_VARIANT_VISIBILITY_SCRIPT_FN_NAME}(${JSON.stringify(variants)}, "${blockId}", ${isHydrationTarget}${locale ? `, "${locale}"` : ''})`; }; +export const getSetupGlobalBuilderContextScript = () => { + return `window.${SETUP_GLOBAL_BUILDER_CONTEXT_SCRIPT_FN_NAME}()`; +}; + export { filterWithCustomTargeting } from './helpers/inlined-fns.js'; diff --git a/packages/sdks/src/components/content-variants/helpers.ts b/packages/sdks/src/components/content-variants/helpers.ts index 8e93e66b58c..73bc73211a7 100644 --- a/packages/sdks/src/components/content-variants/helpers.ts +++ b/packages/sdks/src/components/content-variants/helpers.ts @@ -4,6 +4,7 @@ import type { Nullable } from '../../helpers/nullable.js'; import type { BuilderContent } from '../../types/builder-content.js'; import type { Target } from '../../types/targets.js'; import { + SETUP_GLOBAL_BUILDER_CONTEXT_SCRIPT, UPDATE_COOKIES_AND_STYLES_SCRIPT, UPDATE_VARIANT_VISIBILITY_SCRIPT, } from './inlined-fns.js'; @@ -16,6 +17,8 @@ import { */ const UPDATE_COOKIES_AND_STYLES_SCRIPT_NAME = 'builderIoAbTest'; const UPDATE_VARIANT_VISIBILITY_SCRIPT_FN_NAME = 'builderIoRenderContent'; +const SETUP_GLOBAL_BUILDER_CONTEXT_SCRIPT_FN_NAME = + 'builderIoInitializeGlobalBuilderContext'; export const getVariants = (content: Nullable) => Object.values(content?.variations || {}).map((variant) => ({ @@ -72,6 +75,7 @@ const isHydrationTarget = getIsHydrationTarget(TARGET); export const getInitVariantsFnsScriptString = () => ` window.${UPDATE_COOKIES_AND_STYLES_SCRIPT_NAME} = ${UPDATE_COOKIES_AND_STYLES_SCRIPT} window.${UPDATE_VARIANT_VISIBILITY_SCRIPT_FN_NAME} = ${UPDATE_VARIANT_VISIBILITY_SCRIPT} + window.${SETUP_GLOBAL_BUILDER_CONTEXT_SCRIPT_FN_NAME} = ${SETUP_GLOBAL_BUILDER_CONTEXT_SCRIPT} `; export const getUpdateCookieAndStylesScript = ( diff --git a/packages/sdks/src/components/content-variants/inlined-fns.ts b/packages/sdks/src/components/content-variants/inlined-fns.ts index f0524f835d8..b017ae91046 100644 --- a/packages/sdks/src/components/content-variants/inlined-fns.ts +++ b/packages/sdks/src/components/content-variants/inlined-fns.ts @@ -3,6 +3,98 @@ * They cannot import anything. */ +/** + * Global Builder context singleton to store and retrieve Builder configuration + * across the application without prop drilling. + */ + +export interface GlobalBuilderContext { + apiKey?: string; + apiHost?: string; + contentId?: string; +} + +// Define the global Builder object structure +interface GlobalBuilder { + globalContext: GlobalBuilderContext; + setContext: (context: GlobalBuilderContext) => void; + getContext: () => GlobalBuilderContext; + getValue: ( + key: K + ) => GlobalBuilderContext[K]; + clearContext: () => void; +} + +/** + * Initialize the GlobalBuilderContext on the global/window object + * This function sets up the Builder context functions globally + */ +export function initializeGlobalBuilderContext(): void { + // Detect environment and get the appropriate global object + const isServer = typeof window === 'undefined'; + const globalObject = isServer ? global : window; + + if ((globalObject as any).GlobalBuilderContext) { + // if already exists, don't re-initialize + return; + } + + /** + * Singleton instance to store the global Builder context + */ + const globalContext: GlobalBuilderContext = {}; + /** + * Set the global Builder context + * @param context - The context values to set + */ + function setGlobalBuilderContext( + this: any, + context: GlobalBuilderContext + ): void { + this.globalContext = { ...this.globalContext, ...context }; + } + + /** + * Get the global Builder context + * @returns The current global Builder context + */ + function getGlobalBuilderContext(this: any): GlobalBuilderContext { + return this.globalContext; + } + + /** + * Get a specific value from the global Builder context + * @param key - The key to retrieve + * @returns The value for the specified key + */ + function getGlobalBuilderValue( + this: any, + key: K + ): GlobalBuilderContext[K] { + return this.globalContext[key]; + } + + /** + * Clear the global Builder context + */ + function clearGlobalBuilderContext(this: any): void { + this.globalContext = {}; + } + + // Attach Builder functions to the global object + if (globalObject) { + (globalObject as any).GlobalBuilderContext = + (globalObject as any).GlobalBuilderContext || {}; + const globalBuilderContext = (globalObject as any) + .GlobalBuilderContext as GlobalBuilder; + globalBuilderContext.globalContext = globalContext; + + globalBuilderContext.setContext = setGlobalBuilderContext; + globalBuilderContext.getContext = getGlobalBuilderContext; + globalBuilderContext.getValue = getGlobalBuilderValue; + globalBuilderContext.clearContext = clearGlobalBuilderContext; + } +} type VariantData = { id: string; testRatio?: number; @@ -34,6 +126,48 @@ function updateCookiesAndStyles( '; path=/' + '; Secure; SameSite=None'; } + + function parseUrlParams(url: string): Map { + const result = new Map(); + + try { + const urlObj = new URL(url); + const params = urlObj.searchParams; + + for (const [key, value] of params) { + result.set(key, value); + } + } catch (error) { + console.debug('Error parsing URL parameters:', error); + } + + return result; + } + function getVariantIdFromUrl() { + if (typeof window === 'undefined') return; + const testCookiePrefix = 'builder.tests'; + try { + // Use native URL object to parse current page URL + const params = parseUrlParams(window.location.href); + + // Look for parameters that start with 'builder.tests.' + for (const [key, value] of params) { + if (key.startsWith(`${testCookiePrefix}.${contentId}`)) { + return [key, value]; + } + } + return; + } catch (e) { + console.debug('Error parsing tests from URL', e); + return; + } + } + const builderTestQueryParam = getVariantIdFromUrl(); + if (builderTestQueryParam) { + const [key, value] = builderTestQueryParam; + setCookie(key, value, 30); + } + function getCookie(name: string) { const nameEQ = name + '='; const ca = document.cookie.split(';'); @@ -187,3 +321,5 @@ export const UPDATE_COOKIES_AND_STYLES_SCRIPT = updateCookiesAndStyles export const UPDATE_VARIANT_VISIBILITY_SCRIPT = updateVariantVisibility .toString() .replace(/\s+/g, ' '); +export const SETUP_GLOBAL_BUILDER_CONTEXT_SCRIPT = + initializeGlobalBuilderContext.toString().replace(/\s+/g, ' '); diff --git a/packages/sdks/src/components/content/content.lite.tsx b/packages/sdks/src/components/content/content.lite.tsx index c3df6d97be7..c42fbbb1eeb 100644 --- a/packages/sdks/src/components/content/content.lite.tsx +++ b/packages/sdks/src/components/content/content.lite.tsx @@ -7,6 +7,8 @@ import { useStore, useTarget, } from '@builder.io/mitosis'; +import { getSetupGlobalBuilderContextScript } from '../../blocks/personalization-container/helpers.js'; +import { initializeGlobalBuilderContext } from '../../components/content-variants/inlined-fns.js'; import { getDefaultRegisteredComponents } from '../../constants/builder-registered-components.js'; import { TARGET } from '../../constants/target.js'; import ComponentsContext from '../../context/components.context.lite.js'; @@ -15,10 +17,7 @@ import type { BuilderRenderState, RegisteredComponents, } from '../../context/types.js'; -import { setTestsFromUrl } from '../../functions/content-variants.js'; import { evaluate } from '../../functions/evaluate/evaluate.js'; -import { setGlobalBuilderContext } from '../../functions/global-context.js'; -import { isBrowser } from '../../functions/is-browser.js'; import { serializeIncludingFunctions } from '../../functions/register-component.js'; import { logger } from '../../helpers/logger.js'; import type { ComponentInfo } from '../../types/components.js'; @@ -56,6 +55,16 @@ export default function ContentComponent(props: ContentProps) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-non-null-asserted-optional-chain contentId: props.content?.id!, }), + setGlobalContextScriptStr: props.isNestedRender + ? '' + : ` + ${getSetupGlobalBuilderContextScript()} + window.GlobalBuilderContext.setContext({ + apiKey: '${props.apiKey || ''}', + apiHost: '${props.apiHost || ''}', + contentId: '${props.content?.id || ''}', + }); + `, contentSetState: (newRootState: BuilderRenderState) => { builderContextSignal.value.rootState = newRootState; }, @@ -141,18 +150,17 @@ export default function ContentComponent(props: ContentProps) { 'No API key provided to `Content` component. This can cause issues. Please provide an API key using the `apiKey` prop.' ); } + initializeGlobalBuilderContext(); if (!props.isNestedRender) { - setGlobalBuilderContext({ + ( + (typeof window !== 'undefined' ? window : global) as any + )?.GlobalBuilderContext?.setContext({ apiKey: props.apiKey, apiHost: props.apiHost, contentId: props.content?.id, }); } - if (isBrowser()) { - setTestsFromUrl(); - } - // run any dynamic JS code attached to content const jsCode = builderContextSignal.value.content?.data?.jsCode; @@ -229,6 +237,12 @@ export default function ContentComponent(props: ContentProps) { nonce={props.nonce || ''} /> + + ({ _track: vi.fn(), })); -vi.mock('../global-context.js', () => ({ - getGlobalBuilderContext: vi.fn(), -})); - vi.mock('../content-variants.js', () => ({ getTestCookie: vi.fn(), })); @@ -25,7 +22,12 @@ vi.mock('../../helpers/canTrack.js', () => ({ describe('getBuilderGlobals', () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(getGlobalBuilderContext).mockReturnValue({}); + + (typeof window !== 'undefined' + ? window + : (global as any) + ).GlobalBuilderContext.globalContext = {}; + vi.mocked(getDefaultCanTrack).mockReturnValue(true); vi.mocked(getTestCookie).mockReturnValue(undefined); }); @@ -38,7 +40,11 @@ describe('getBuilderGlobals', () => { apiKey: 'test-api-key', }; - vi.mocked(getGlobalBuilderContext).mockReturnValue(mockContext); + (typeof window !== 'undefined' + ? window + : (global as any) + ).GlobalBuilderContext.setContext(mockContext); + vi.mocked(getDefaultCanTrack).mockReturnValue(true); builderGlobals.track('click', { customProp: 'value' }, { userId: '123' }); @@ -55,7 +61,10 @@ describe('getBuilderGlobals', () => { it('should use empty string for apiKey when not provided', () => { const builderGlobals = getBuilderGlobals(); - vi.mocked(getGlobalBuilderContext).mockReturnValue({ + (typeof window !== 'undefined' + ? window + : (global as any) + ).GlobalBuilderContext.setContext({ apiHost: 'https://test.builder.io', }); @@ -71,6 +80,10 @@ describe('getBuilderGlobals', () => { }); it('should work with no properties provided', () => { + (typeof window !== 'undefined' + ? window + : (global as any) + ).GlobalBuilderContext.globalContext = {}; const builderGlobals = getBuilderGlobals(); builderGlobals.track('custom-event', {}); @@ -94,7 +107,10 @@ describe('getBuilderGlobals', () => { contentId: 'content-123', }; - vi.mocked(getGlobalBuilderContext).mockReturnValue(mockContext); + (typeof window !== 'undefined' + ? window + : (global as any) + ).GlobalBuilderContext.setContext(mockContext); vi.mocked(getDefaultCanTrack).mockReturnValue(true); builderGlobals.trackConversion( @@ -125,7 +141,10 @@ describe('getBuilderGlobals', () => { contentId: 'global-content-123', }; - vi.mocked(getGlobalBuilderContext).mockReturnValue(mockContext); + (typeof window !== 'undefined' + ? window + : (global as any) + ).GlobalBuilderContext.setContext(mockContext); builderGlobals.trackConversion(50); @@ -146,9 +165,12 @@ describe('getBuilderGlobals', () => { const builderGlobals = getBuilderGlobals(); const metaObject = { product: 'shoes', category: 'footwear' }; - vi.mocked(getGlobalBuilderContext).mockReturnValue({ + (typeof window !== 'undefined' + ? window + : (global as any) + ).GlobalBuilderContext.globalContext = { apiKey: 'test-key', - }); + }; builderGlobals.trackConversion(75, metaObject, 'variation-123'); @@ -168,9 +190,12 @@ describe('getBuilderGlobals', () => { it('should get variationId from test cookie when not provided', () => { const builderGlobals = getBuilderGlobals(); - vi.mocked(getGlobalBuilderContext).mockReturnValue({ + (typeof window !== 'undefined' + ? window + : (global as any) + ).GlobalBuilderContext.globalContext = { apiKey: 'test-key', - }); + }; vi.mocked(getTestCookie).mockReturnValue('cookie-variation-456'); builderGlobals.trackConversion(25, 'content-789'); @@ -192,7 +217,10 @@ describe('getBuilderGlobals', () => { it('should not set variationId when it equals contentId', () => { const builderGlobals = getBuilderGlobals(); - vi.mocked(getGlobalBuilderContext).mockReturnValue({ + (typeof window !== 'undefined' + ? window + : (global as any) + ).GlobalBuilderContext.setContext({ apiKey: 'test-key', }); @@ -214,7 +242,10 @@ describe('getBuilderGlobals', () => { it('should handle all parameters as undefined', () => { const builderGlobals = getBuilderGlobals(); - vi.mocked(getGlobalBuilderContext).mockReturnValue({}); + (typeof window !== 'undefined' + ? window + : (global as any) + ).GlobalBuilderContext.globalContext = {}; builderGlobals.trackConversion(); @@ -238,7 +269,10 @@ describe('getBuilderGlobals', () => { contentId: 'global-content-123', }; - vi.mocked(getGlobalBuilderContext).mockReturnValue(mockContext); + (typeof window !== 'undefined' + ? window + : (global as any) + ).GlobalBuilderContext.globalContext = mockContext; builderGlobals.trackConversion(100, 'explicit-content-456'); diff --git a/packages/sdks/src/functions/evaluate/helpers.ts b/packages/sdks/src/functions/evaluate/helpers.ts index fb527c79c86..25e6f184d60 100644 --- a/packages/sdks/src/functions/evaluate/helpers.ts +++ b/packages/sdks/src/functions/evaluate/helpers.ts @@ -4,7 +4,6 @@ import type { } from '../../context/types.js'; import { getDefaultCanTrack } from '../../helpers/canTrack.js'; import { getTestCookie } from '../content-variants.js'; -import { getGlobalBuilderContext } from '../global-context.js'; import { isBrowser } from '../is-browser.js'; import { isEditing } from '../is-editing.js'; import { getUserAttributes } from '../track/helpers.js'; @@ -74,7 +73,9 @@ export const getBuilderGlobals = (): BuilderGlobals => ({ properties: Partial = {}, context?: any ) => { - const builderContext = getGlobalBuilderContext(); + const builderContext = ( + typeof window !== 'undefined' ? window : (global as any) + )?.GlobalBuilderContext?.getContext(); _track({ type: eventName, ...properties, @@ -93,7 +94,9 @@ export const getBuilderGlobals = (): BuilderGlobals => ({ ) => { const meta = typeof contentId === 'object' ? contentId : customProperties; let useContentId = typeof contentId === 'string' ? contentId : undefined; - const builderContext = getGlobalBuilderContext(); + const builderContext = ( + typeof window !== 'undefined' ? window : (global as any) + )?.GlobalBuilderContext?.getContext(); if (!useContentId && builderContext?.contentId) { useContentId = builderContext.contentId; diff --git a/packages/sdks/src/functions/global-context.ts b/packages/sdks/src/functions/global-context.ts deleted file mode 100644 index 0f88f2517fc..00000000000 --- a/packages/sdks/src/functions/global-context.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Global Builder context singleton to store and retrieve Builder configuration - * across the application without prop drilling. - */ - -export interface GlobalBuilderContext { - apiKey?: string; - apiHost?: string; - contentId?: string; -} - -/** - * Singleton instance to store the global Builder context - */ -class BuilderGlobalContext { - private static instance: BuilderGlobalContext; - private context: GlobalBuilderContext = {}; - - private constructor() {} - - /** - * Get the singleton instance - */ - public static getInstance(): BuilderGlobalContext { - if (!BuilderGlobalContext.instance) { - BuilderGlobalContext.instance = new BuilderGlobalContext(); - } - return BuilderGlobalContext.instance; - } - - /** - * Set the global context values - */ - public setContext(context: GlobalBuilderContext): void { - this.context = { ...this.context, ...context }; - } - - /** - * Get the current global context - */ - public getContext(): GlobalBuilderContext { - return { ...this.context }; - } - - /** - * Clear the global context - */ - public clearContext(): void { - this.context = {}; - } - - /** - * Get a specific value from the context - */ - public getValue( - key: K - ): GlobalBuilderContext[K] { - return this.context[key]; - } -} - -/** - * Set the global Builder context - * @param context - The context values to set - */ -export function setGlobalBuilderContext(context: GlobalBuilderContext): void { - BuilderGlobalContext.getInstance().setContext(context); -} - -/** - * Get the global Builder context - * @returns The current global Builder context - */ -export function getGlobalBuilderContext(): GlobalBuilderContext { - return BuilderGlobalContext.getInstance().getContext(); -} - -/** - * Get a specific value from the global Builder context - * @param key - The key to retrieve - * @returns The value for the specified key - */ -export function getGlobalBuilderValue( - key: K -): GlobalBuilderContext[K] { - return BuilderGlobalContext.getInstance().getValue(key); -} - -/** - * Clear the global Builder context - */ -export function clearGlobalBuilderContext(): void { - BuilderGlobalContext.getInstance().clearContext(); -} From ecdad57574cef5e6b1e1bed7a8fbf37b83aa21bc Mon Sep 17 00:00:00 2001 From: Midhun A Darvin Date: Tue, 30 Sep 2025 11:06:33 +0530 Subject: [PATCH 10/14] fix: tests --- .../src/e2e-tests/track-conversion.spec.ts | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/packages/sdks-tests/src/e2e-tests/track-conversion.spec.ts b/packages/sdks-tests/src/e2e-tests/track-conversion.spec.ts index 89803063a10..6f759225bc5 100644 --- a/packages/sdks-tests/src/e2e-tests/track-conversion.spec.ts +++ b/packages/sdks-tests/src/e2e-tests/track-conversion.spec.ts @@ -1,6 +1,6 @@ import type { Browser } from '@playwright/test'; import { expect } from '@playwright/test'; -import { excludeGen1, excludeRn, test } from '../helpers/index.js'; +import { excludeGen1, excludeRn, isSSRFramework, test } from '../helpers/index.js'; import { CONVERSION_SYMBOL_CONTENT } from '../specs/symbol-with-conversion.js'; import { CONVERSION_SECTION_CONTENT } from '../specs/section-with-conversion.js'; @@ -45,8 +45,7 @@ export const initializeAbTest = async ( ) => { if (!baseURL) throw new Error('Missing baseURL'); - // RN can't have SSR, we don't support/export it. - test.skip(packageName === 'react-native-74' || packageName === 'react-native-76-fabric'); + test.skip(isSSRFramework(packageName)); /** * This test is flaky on `nextjs-sdk-next-app` and `qwik-city`. Most likely because it is the very first test that runs. @@ -304,12 +303,7 @@ test.describe('Track Conversion', () => { browser, }) => { test.skip(excludeGen1(sdk) || excludeRn(sdk)); - test.skip( - packageName === 'nextjs-sdk-next-app' || - packageName === 'gen1-next14-pages' || - packageName === 'gen1-next15-app' || - packageName === 'gen1-remix' - ); + test.skip(isSSRFramework(packageName)); const { page: testPage } = await initializeAbTest( { @@ -434,12 +428,7 @@ test.describe('Track Conversion', () => { browser, }) => { test.skip(excludeGen1(sdk) || excludeRn(sdk)); - test.skip( - packageName === 'nextjs-sdk-next-app' || - packageName === 'gen1-next14-pages' || - packageName === 'gen1-next15-app' || - packageName === 'gen1-remix' - ); + test.skip(isSSRFramework(packageName)); const { page: testPage } = await initializeAbTest( { From a4ae75a7782e2cfa7f60553a0ed7a0ec02e21285 Mon Sep 17 00:00:00 2001 From: Midhun A Darvin Date: Tue, 30 Sep 2025 11:53:47 +0530 Subject: [PATCH 11/14] fix: tests --- packages/sdks-tests/src/specs/track-conversion.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/sdks-tests/src/specs/track-conversion.ts b/packages/sdks-tests/src/specs/track-conversion.ts index 6fcfb102e45..b3312437230 100644 --- a/packages/sdks-tests/src/specs/track-conversion.ts +++ b/packages/sdks-tests/src/specs/track-conversion.ts @@ -105,7 +105,7 @@ export const TRACK_CONVERSION_CONTENT = { click: 'builder.trackConversion();\n', }, }, - id: 'builder-3b4d00a8858c4ebc951bf0860543dc67', + id: 'builder-3b4d00a8858c4ebc951bf0860543dc61', meta: { eventActions: { click: [ @@ -160,7 +160,7 @@ export const TRACK_CONVERSION_CONTENT = { click: 'builder.trackConversion(100);\n', }, }, - id: 'builder-3b4d00a8858c4ebc951bf0860543dc67', + id: 'builder-3b4d00a8858c4ebc951bf0860543dc62', meta: { eventActions: { '': [], @@ -218,7 +218,7 @@ export const TRACK_CONVERSION_CONTENT = { "builder.trackConversion(100, 'test-content-id', 'test-variation-id', { product: 'premium-shoes' }, { userId: 'user-123' });\n", }, }, - id: 'builder-3b4d00a8858c4ebc951bf0860543dc67', + id: 'builder-3b4d00a8858c4ebc951bf0860543dc63', meta: { eventActions: { '': [], From a07ac265acbb020202fc41c2397c278de46cdca4 Mon Sep 17 00:00:00 2001 From: Midhun A Darvin Date: Tue, 30 Sep 2025 13:02:46 +0530 Subject: [PATCH 12/14] fix: tests --- .../src/components/content-variants/inlined-fns.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/sdks/src/components/content-variants/inlined-fns.ts b/packages/sdks/src/components/content-variants/inlined-fns.ts index b017ae91046..f9a595d1faa 100644 --- a/packages/sdks/src/components/content-variants/inlined-fns.ts +++ b/packages/sdks/src/components/content-variants/inlined-fns.ts @@ -32,7 +32,17 @@ interface GlobalBuilder { export function initializeGlobalBuilderContext(): void { // Detect environment and get the appropriate global object const isServer = typeof window === 'undefined'; - const globalObject = isServer ? global : window; + const globalObject = isServer + ? typeof globalThis !== 'undefined' + ? globalThis + : (function () { + try { + return global; + } catch (e) { + return {}; + } + })() + : window; if ((globalObject as any).GlobalBuilderContext) { // if already exists, don't re-initialize From 9475c04c8adfbc9ebae3528e776437ad0b5d1f37 Mon Sep 17 00:00:00 2001 From: Midhun A Darvin Date: Tue, 30 Sep 2025 15:29:24 +0530 Subject: [PATCH 13/14] fix: test --- packages/sdks/src/components/content/content.lite.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/sdks/src/components/content/content.lite.tsx b/packages/sdks/src/components/content/content.lite.tsx index c42fbbb1eeb..e5938a14b58 100644 --- a/packages/sdks/src/components/content/content.lite.tsx +++ b/packages/sdks/src/components/content/content.lite.tsx @@ -18,6 +18,7 @@ import type { RegisteredComponents, } from '../../context/types.js'; import { evaluate } from '../../functions/evaluate/evaluate.js'; +import { getGlobalThis } from '../../functions/get-global-this.js'; import { serializeIncludingFunctions } from '../../functions/register-component.js'; import { logger } from '../../helpers/logger.js'; import type { ComponentInfo } from '../../types/components.js'; @@ -152,9 +153,7 @@ export default function ContentComponent(props: ContentProps) { } initializeGlobalBuilderContext(); if (!props.isNestedRender) { - ( - (typeof window !== 'undefined' ? window : global) as any - )?.GlobalBuilderContext?.setContext({ + (getGlobalThis() as any)?.GlobalBuilderContext?.setContext({ apiKey: props.apiKey, apiHost: props.apiHost, contentId: props.content?.id, From 2ff9c7f6c16447b787719391381b54d5df8ccb64 Mon Sep 17 00:00:00 2001 From: Midhun A Darvin Date: Tue, 30 Sep 2025 21:38:08 +0530 Subject: [PATCH 14/14] fix: review comment --- .../sdks/src/functions/content-variants.ts | 54 +------------------ 1 file changed, 1 insertion(+), 53 deletions(-) diff --git a/packages/sdks/src/functions/content-variants.ts b/packages/sdks/src/functions/content-variants.ts index 9c750e5df04..151571109ff 100644 --- a/packages/sdks/src/functions/content-variants.ts +++ b/packages/sdks/src/functions/content-variants.ts @@ -1,4 +1,4 @@ -import { getCookieSync, setCookie } from '../helpers/cookie.js'; +import { getCookieSync } from '../helpers/cookie.js'; export const testCookiePrefix = 'builder.tests'; @@ -8,55 +8,3 @@ export function getTestCookie(name: string) { canTrack: true, }); } - -function parseUrlParams(url: string): Map { - const result = new Map(); - - try { - const urlObj = new URL(url); - const params = urlObj.searchParams; - - for (const [key, value] of params) { - result.set(key, value); - } - } catch (error) { - console.debug('Error parsing URL parameters:', error); - } - - return result; -} - -export function setTestCookie(contentId: string, variationId: string) { - // 30 days from now - const future = new Date(); - future.setDate(future.getDate() + 30); - - // Use the native setCookie function directly - if (typeof window !== 'undefined') { - setCookie({ - name: `${testCookiePrefix}.${contentId}`, - value: variationId, - expires: future, - canTrack: true, - }); - } -} - -export function setTestsFromUrl() { - if (typeof window === 'undefined') return; - - try { - // Use native URL object to parse current page URL - const params = parseUrlParams(window.location.href); - - // Look for parameters that start with 'builder.tests.' - for (const [key, value] of params) { - if (key.startsWith(`${testCookiePrefix}.`)) { - const testKey = key.replace(`${testCookiePrefix}.`, ''); - setTestCookie(testKey, value); - } - } - } catch (e) { - console.debug('Error parsing tests from URL', e); - } -}