From 6dee4e45079e1b32647c1028137a7f2302d530af Mon Sep 17 00:00:00 2001 From: Seth Silesky <5115498+silesky@users.noreply.github.com> Date: Thu, 26 Oct 2023 23:08:37 -0500 Subject: [PATCH] rename 'shouldLoad' -> 'shouldLoadSegment' and shouldLoadWrapper API --- .changeset/fair-pillows-sin.md | 5 + packages/consent/consent-tools/README.md | 25 ++--- .../domain/__tests__/create-wrapper.test.ts | 92 ++++++++++--------- .../src/domain/create-wrapper.ts | 21 +++-- .../domain/validation/options-validators.ts | 3 +- .../consent-tools/src/types/settings.ts | 18 +++- .../src/domain/__tests__/wrapper.test.ts | 24 +++-- .../src/domain/wrapper.ts | 10 +- .../src/test-helpers/mocks.ts | 19 ++-- .../src/test-helpers/utils.ts | 11 ++- 10 files changed, 133 insertions(+), 95 deletions(-) create mode 100644 .changeset/fair-pillows-sin.md diff --git a/.changeset/fair-pillows-sin.md b/.changeset/fair-pillows-sin.md new file mode 100644 index 000000000..4e7965db2 --- /dev/null +++ b/.changeset/fair-pillows-sin.md @@ -0,0 +1,5 @@ +--- +'@segment/analytics-consent-tools': major +--- + +Rename shouldLoad -> shouldLoadSegment and add shouldLoadWrapper API diff --git a/packages/consent/consent-tools/README.md b/packages/consent/consent-tools/README.md index 5f590bfb7..2c616082f 100644 --- a/packages/consent/consent-tools/README.md +++ b/packages/consent/consent-tools/README.md @@ -7,12 +7,15 @@ import { createWrapper, resolveWhen } from '@segment/analytics-consent-tools' export const withCMP = createWrapper({ + // Wait to load wrapper or call "shouldLoadSegment" until window.CMP exists. + shouldLoadWrapper: async () => { + await resolveWhen(() => window.CMP !== undefined, 500) + }, // Wrapper waits to load segment / get categories until this function returns / resolves - shouldLoad: async (ctx) => { - const CMP = await getCMP() + shouldLoadSegment: async (ctx) => { await resolveWhen( - () => !CMP.popUpVisible(), + () => !window.CMP.popUpVisible(), 500 ) @@ -24,24 +27,16 @@ export const withCMP = createWrapper({ } }, - getCategories: async () => { - const CMP = await getCMP() - return normalizeCategories(CMP.consentedCategories()) // Expected format: { foo: true, bar: false } + getCategories: () => { + return normalizeCategories(window.CMP.consentedCategories()) // Expected format: { foo: true, bar: false } }, - registerOnConsentChanged: async (setCategories) => { - const CMP = await getCMP() - CMP.onConsentChanged((event) => { + registerOnConsentChanged: (setCategories) => { + window.CMP.onConsentChanged((event) => { setCategories(normalizeCategories(event.detail)) }) }, }) - - -const getCMP = async () => { - await resolveWhen(() => window.CMP !== undefined, 500) - return window.CMP -} ``` ## Wrapper Usage API diff --git a/packages/consent/consent-tools/src/domain/__tests__/create-wrapper.test.ts b/packages/consent/consent-tools/src/domain/__tests__/create-wrapper.test.ts index 4ba852d98..d1ab1b11e 100644 --- a/packages/consent/consent-tools/src/domain/__tests__/create-wrapper.test.ts +++ b/packages/consent/consent-tools/src/domain/__tests__/create-wrapper.test.ts @@ -123,29 +123,31 @@ describe(createWrapper, () => { expect(args.length).toBeTruthy() }) - describe('shouldLoad', () => { + describe('shouldLoadSegment', () => { describe('Throwing errors / aborting load', () => { const createShouldLoadThatThrows = ( ...args: Parameters ) => { let err: Error - const shouldLoad = jest.fn().mockImplementation((ctx: LoadContext) => { - try { - ctx.abort(...args) - throw new Error('Fail') - } catch (_err: any) { - err = _err - } - }) - return { shouldLoad, getError: () => err } + const shouldLoadSegment = jest + .fn() + .mockImplementation((ctx: LoadContext) => { + try { + ctx.abort(...args) + throw new Error('Fail') + } catch (_err: any) { + err = _err + } + }) + return { shouldLoadSegment, getError: () => err } } it('should throw a special error if ctx.abort is called', async () => { - const { shouldLoad, getError } = createShouldLoadThatThrows({ + const { shouldLoadSegment, getError } = createShouldLoadThatThrows({ loadSegmentNormally: true, }) wrapTestAnalytics({ - shouldLoad, + shouldLoadSegment, }) await analytics.load(DEFAULT_LOAD_SETTINGS) expect(getError() instanceof AbortLoadError).toBeTruthy() @@ -155,7 +157,7 @@ describe(createWrapper, () => { `should not log a console error or throw an error if ctx.abort is called (%p)`, async (args) => { wrapTestAnalytics({ - shouldLoad: (ctx) => ctx.abort(args), + shouldLoadSegment: (ctx) => ctx.abort(args), }) const result = await analytics.load(DEFAULT_LOAD_SETTINGS) expect(result).toBeUndefined() @@ -165,7 +167,7 @@ describe(createWrapper, () => { it('should allow segment to be loaded normally (with all consent wrapper behavior disabled) via ctx.abort', async () => { wrapTestAnalytics({ - shouldLoad: (ctx) => { + shouldLoadSegment: (ctx) => { ctx.abort({ loadSegmentNormally: true, // magic config option }) @@ -178,7 +180,7 @@ describe(createWrapper, () => { it('should allow segment loading to be completely aborted via ctx.abort', async () => { wrapTestAnalytics({ - shouldLoad: (ctx) => { + shouldLoadSegment: (ctx) => { ctx.abort({ loadSegmentNormally: false, // magic config option }) @@ -189,11 +191,11 @@ describe(createWrapper, () => { expect(analyticsLoadSpy).not.toBeCalled() }) it('should throw a validation error if ctx.abort is called incorrectly', async () => { - const { getError, shouldLoad } = createShouldLoadThatThrows( + const { getError, shouldLoadSegment } = createShouldLoadThatThrows( undefined as any ) wrapTestAnalytics({ - shouldLoad, + shouldLoadSegment, }) await analytics.load(DEFAULT_LOAD_SETTINGS) expect(getError().message).toMatch(/validation/i) @@ -202,7 +204,7 @@ describe(createWrapper, () => { it('An unrecognized Error (non-consent) error should bubble up, but we should not log any additional console error', async () => { const err = new Error('hello') wrapTestAnalytics({ - shouldLoad: () => { + shouldLoadSegment: () => { throw err }, }) @@ -215,7 +217,7 @@ describe(createWrapper, () => { expect(analyticsLoadSpy).not.toBeCalled() }) }) - it('should first call shouldLoad(), then wait for it to resolve/return before calling analytics.load()', async () => { + it('should first call shouldLoadSegment(), then wait for it to resolve/return before calling analytics.load()', async () => { const fnCalls: string[] = [] analyticsLoadSpy.mockImplementationOnce(() => { fnCalls.push('analytics.load') @@ -224,31 +226,31 @@ describe(createWrapper, () => { const shouldLoadMock: jest.Mock = jest .fn() .mockImplementationOnce(async () => { - fnCalls.push('shouldLoad') + fnCalls.push('shouldLoadSegment') }) wrapTestAnalytics({ - shouldLoad: shouldLoadMock, + shouldLoadSegment: shouldLoadMock, }) await analytics.load(DEFAULT_LOAD_SETTINGS) - expect(fnCalls).toEqual(['shouldLoad', 'analytics.load']) + expect(fnCalls).toEqual(['shouldLoadSegment', 'analytics.load']) }) }) describe('getCategories', () => { test.each([ { - shouldLoad: () => undefined, + shouldLoadSegment: () => undefined, returnVal: 'undefined', }, { - shouldLoad: () => Promise.resolve(undefined), + shouldLoadSegment: () => Promise.resolve(undefined), returnVal: 'Promise', }, ])( - 'if shouldLoad() returns nil ($returnVal), intial categories will come from getCategories()', - async ({ shouldLoad }) => { + 'if shouldLoadSegment() returns nil ($returnVal), intial categories will come from getCategories()', + async ({ shouldLoadSegment }) => { const mockCdnSettings = { integrations: { mockIntegration: { @@ -258,7 +260,7 @@ describe(createWrapper, () => { } wrapTestAnalytics({ - shouldLoad: shouldLoad, + shouldLoadSegment: shouldLoadSegment, }) await analytics.load({ ...DEFAULT_LOAD_SETTINGS, @@ -282,7 +284,7 @@ describe(createWrapper, () => { returnVal: 'Promise', }, ])( - 'if shouldLoad() returns categories ($returnVal), those will be the initial categories', + 'if shouldLoadSegment() returns categories ($returnVal), those will be the initial categories', async ({ getCategories }) => { const mockCdnSettings = { integrations: { @@ -296,7 +298,7 @@ describe(createWrapper, () => { wrapTestAnalytics({ getCategories: mockGetCategories, - shouldLoad: () => undefined, + shouldLoadSegment: () => undefined, }) await analytics.load({ ...DEFAULT_LOAD_SETTINGS, @@ -321,7 +323,7 @@ describe(createWrapper, () => { test('analytics.load should reject if categories are in the wrong format', async () => { wrapTestAnalytics({ - shouldLoad: () => Promise.resolve('sup' as any), + shouldLoadSegment: () => Promise.resolve('sup' as any), }) await expect(() => analytics.load(DEFAULT_LOAD_SETTINGS)).rejects.toThrow( /validation/i @@ -331,7 +333,7 @@ describe(createWrapper, () => { test('analytics.load should reject if categories are undefined', async () => { wrapTestAnalytics({ getCategories: () => undefined as any, - shouldLoad: () => undefined, + shouldLoadSegment: () => undefined, }) await expect(() => analytics.load(DEFAULT_LOAD_SETTINGS)).rejects.toThrow( /validation/i @@ -390,7 +392,7 @@ describe(createWrapper, () => { .build() wrapTestAnalytics({ - shouldLoad: () => ({ Foo: true }), + shouldLoadSegment: () => ({ Foo: true }), }) await analytics.load({ ...DEFAULT_LOAD_SETTINGS, @@ -415,7 +417,7 @@ describe(createWrapper, () => { .build() wrapTestAnalytics({ - shouldLoad: () => ({ Foo: true, Bar: true }), + shouldLoadSegment: () => ({ Foo: true, Bar: true }), }) await analytics.load({ ...DEFAULT_LOAD_SETTINGS, @@ -440,7 +442,7 @@ describe(createWrapper, () => { .build() wrapTestAnalytics({ - shouldLoad: () => ({ Foo: true }), + shouldLoadSegment: () => ({ Foo: true }), }) await analytics.load({ ...DEFAULT_LOAD_SETTINGS, @@ -466,34 +468,34 @@ describe(createWrapper, () => { expect(analyticsLoadSpy).toBeCalled() }) - it('should not call shouldLoad if called on first', async () => { - const shouldLoad = jest.fn() + it('should not call shouldLoadSegment if called on first', async () => { + const shouldLoadSegment = jest.fn() wrapTestAnalytics({ shouldDisableConsentRequirement: () => true, - shouldLoad, + shouldLoadSegment, }) await analytics.load(DEFAULT_LOAD_SETTINGS) - expect(shouldLoad).not.toBeCalled() + expect(shouldLoadSegment).not.toBeCalled() }) it('should work with promises if false', async () => { - const shouldLoad = jest.fn() + const shouldLoadSegment = jest.fn() wrapTestAnalytics({ shouldDisableConsentRequirement: () => Promise.resolve(false), - shouldLoad, + shouldLoadSegment, }) await analytics.load(DEFAULT_LOAD_SETTINGS) - expect(shouldLoad).toBeCalled() + expect(shouldLoadSegment).toBeCalled() }) it('should work with promises if true', async () => { - const shouldLoad = jest.fn() + const shouldLoadSegment = jest.fn() wrapTestAnalytics({ shouldDisableConsentRequirement: () => Promise.resolve(true), - shouldLoad, + shouldLoadSegment, }) await analytics.load(DEFAULT_LOAD_SETTINGS) - expect(shouldLoad).not.toBeCalled() + expect(shouldLoadSegment).not.toBeCalled() }) it('should forward all arguments to the original analytics.load method', async () => { @@ -567,7 +569,7 @@ describe(createWrapper, () => { wrapTestAnalytics({ getCategories, - shouldLoad: () => { + shouldLoadSegment: () => { // on first load, we should not get an error because this is a valid category setting return { invalidCategory: true } }, diff --git a/packages/consent/consent-tools/src/domain/create-wrapper.ts b/packages/consent/consent-tools/src/domain/create-wrapper.ts index 97a417fcc..1e90b1b16 100644 --- a/packages/consent/consent-tools/src/domain/create-wrapper.ts +++ b/packages/consent/consent-tools/src/domain/create-wrapper.ts @@ -26,21 +26,24 @@ export const createWrapper = ( shouldDisableSegment, shouldDisableConsentRequirement, getCategories, - shouldLoad, + shouldLoadSegment, integrationCategoryMappings, shouldEnableIntegration, pruneUnmappedCategories, registerOnConsentChanged, + shouldLoadWrapper, } = createWrapperOptions return (analytics: Analytics) => { validateAnalyticsInstance(analytics) - - // Call this function as early as possible. OnConsentChanged events can happen before .load is called. - registerOnConsentChanged?.((categories) => - // whenever consent changes, dispatch a new event with the latest consent information - validateAndSendConsentChangedEvent(analytics, categories) - ) + const loadWrapper = shouldLoadWrapper?.() || Promise.resolve() + void loadWrapper.then(() => { + // Call this function as early as possible. OnConsentChanged events can happen before .load is called. + registerOnConsentChanged?.((categories) => + // whenever consent changes, dispatch a new event with the latest consent information + validateAndSendConsentChangedEvent(analytics, categories) + ) + }) const ogLoad = analytics.load @@ -53,6 +56,7 @@ export const createWrapper = ( return } + await loadWrapper const consentRequirementDisabled = await shouldDisableConsentRequirement?.() if (consentRequirementDisabled) { @@ -64,7 +68,8 @@ export const createWrapper = ( let initialCategories: Categories try { initialCategories = - (await shouldLoad?.(new LoadContext())) || (await getCategories()) + (await shouldLoadSegment?.(new LoadContext())) || + (await getCategories()) } catch (e: unknown) { // consumer can call ctx.abort({ loadSegmentNormally: true }) // to load Segment but disable consent requirement diff --git a/packages/consent/consent-tools/src/domain/validation/options-validators.ts b/packages/consent/consent-tools/src/domain/validation/options-validators.ts index a07af6ac0..061d1e713 100644 --- a/packages/consent/consent-tools/src/domain/validation/options-validators.ts +++ b/packages/consent/consent-tools/src/domain/validation/options-validators.ts @@ -32,7 +32,8 @@ export function validateSettings(options: { assertIsFunction(options.getCategories, 'getCategories') - options.shouldLoad && assertIsFunction(options.shouldLoad, 'shouldLoad') + options.shouldLoadSegment && + assertIsFunction(options.shouldLoadSegment, 'shouldLoadSegment') options.shouldDisableConsentRequirement && assertIsFunction( diff --git a/packages/consent/consent-tools/src/types/settings.ts b/packages/consent/consent-tools/src/types/settings.ts index 0d637a879..188d00811 100644 --- a/packages/consent/consent-tools/src/types/settings.ts +++ b/packages/consent/consent-tools/src/types/settings.ts @@ -13,12 +13,24 @@ export type RegisterOnConsentChangedFunction = ( * Consent wrapper function configuration */ export interface CreateWrapperSettings { + /** + * Wait until this function's Promise resolves before attempting to initialize the wrapper with any settings passed into it. + * Typically, this is used to wait for a CMP global object (e.g. window.OneTrust) to be available. + * This function is called as early possible in the lifecycle, before `shouldLoadWrapper`, `registerOnConsentChanged` and `getCategories`. + * Throwing an error here will prevent the wrapper from loading (just as if `shouldDisableSegment` returned true). + * @example + * ```ts + * () => resolveWhen(() => window.myCMP !== undefined, 500) + * ``` + **/ + shouldLoadWrapper?: () => Promise + /** * Wait until this function resolves/returns before loading analytics. * This function should return a list of initial categories. * If this function returns `undefined`, `getCategories()` function will be called to get initial categories. **/ - shouldLoad?: ( + shouldLoadSegment?: ( context: LoadContext ) => Categories | void | Promise @@ -61,7 +73,7 @@ export interface CreateWrapperSettings { /** * This permanently disables any consent requirement (i.e device mode gating, event pref stamping). - * Called on wrapper initialization. **shouldLoad will never be called** + * Called on wrapper initialization. **shouldLoadSegment will never be called** **/ shouldDisableConsentRequirement?: () => boolean | Promise @@ -69,7 +81,7 @@ export interface CreateWrapperSettings { * Disable the Segment analytics SDK completely. analytics.load() will have no effect. * .track / .identify etc calls should not throw any errors, but analytics settings will never be fetched and no events will be sent to Segment. * Called on wrapper initialization. This can be useful in dev environments (e.g. 'devMode'). - * **shouldLoad will never be called** + * **shouldLoadSegment will never be called** **/ shouldDisableSegment?: () => boolean | Promise diff --git a/packages/consent/consent-wrapper-onetrust/src/domain/__tests__/wrapper.test.ts b/packages/consent/consent-wrapper-onetrust/src/domain/__tests__/wrapper.test.ts index 933d2daf2..892ee1da5 100644 --- a/packages/consent/consent-wrapper-onetrust/src/domain/__tests__/wrapper.test.ts +++ b/packages/consent/consent-wrapper-onetrust/src/domain/__tests__/wrapper.test.ts @@ -4,10 +4,6 @@ import { sleep } from '@internal/test-helpers' import { withOneTrust } from '../wrapper' import { OneTrustMockGlobal, analyticsMock } from '../../test-helpers/mocks' -const throwNotImplemented = (): never => { - throw new Error('not implemented') -} - const grpFixture = { StrictlyNeccessary: { CustomGroupId: 'C0001', @@ -22,12 +18,17 @@ const grpFixture = { const getConsentedGroupIdsSpy = jest .spyOn(OneTrustAPI, 'getConsentedGroupIds') - .mockImplementationOnce(throwNotImplemented) + .mockImplementationOnce(() => { + throw new Error('not implemented') + }) const createWrapperSpyHelper = { _spy: jest.spyOn(ConsentTools, 'createWrapper'), - get shouldLoad() { - return createWrapperSpyHelper._spy.mock.lastCall[0].shouldLoad! + get shouldLoadWrapper() { + return createWrapperSpyHelper._spy.mock.lastCall[0].shouldLoadWrapper! + }, + get shouldLoadSegment() { + return createWrapperSpyHelper._spy.mock.lastCall[0].shouldLoadSegment! }, get getCategories() { return createWrapperSpyHelper._spy.mock.lastCall[0].getCategories! @@ -37,7 +38,6 @@ const createWrapperSpyHelper = { .registerOnConsentChanged! }, } - /** * These tests are not meant to be comprehensive, but they should cover the most important cases. * We should prefer unit tests for most functionality (see lib/__tests__) @@ -66,7 +66,7 @@ describe('High level "integration" tests', () => { }) }) - describe('shouldLoad', () => { + describe('shouldLoadSegment', () => { it('should be resolved successfully', async () => { withOneTrust(analyticsMock) OneTrustMockGlobal.GetDomainData.mockReturnValueOnce({ @@ -76,7 +76,7 @@ describe('High level "integration" tests', () => { grpFixture.StrictlyNeccessary.CustomGroupId, ]) const shouldLoadP = Promise.resolve( - createWrapperSpyHelper.shouldLoad({} as any) + createWrapperSpyHelper.shouldLoadSegment({} as any) ) let shouldLoadResolved = false void shouldLoadP.then(() => (shouldLoadResolved = true)) @@ -124,12 +124,15 @@ describe('High level "integration" tests', () => { }) const onCategoriesChangedCb = jest.fn() + void createWrapperSpyHelper.shouldLoadWrapper() createWrapperSpyHelper.registerOnConsentChanged(onCategoriesChangedCb) onCategoriesChangedCb() resolveResolveWhen() // wait for OneTrust global to be available await sleep(0) + analyticsMock.track.mockImplementationOnce(() => {}) // ignore track event sent by consent changed + const onConsentChangedArg = OneTrustMockGlobal.OnConsentChanged.mock.lastCall[0] onConsentChangedArg( @@ -140,6 +143,7 @@ describe('High level "integration" tests', () => { ], }) ) + // expect to be normalized! expect(onCategoriesChangedCb.mock.lastCall[0]).toEqual({ [grpFixture.StrictlyNeccessary.CustomGroupId]: true, diff --git a/packages/consent/consent-wrapper-onetrust/src/domain/wrapper.ts b/packages/consent/consent-wrapper-onetrust/src/domain/wrapper.ts index 39458c2cb..6363cf928 100644 --- a/packages/consent/consent-wrapper-onetrust/src/domain/wrapper.ts +++ b/packages/consent/consent-wrapper-onetrust/src/domain/wrapper.ts @@ -39,13 +39,17 @@ export const withOneTrust = ( }) } return createWrapper({ - shouldLoad: async () => { + // wait for OneTrust global to be available before wrapper is loaded + shouldLoadWrapper: async () => { + await resolveWhen(() => getOneTrustGlobal() !== undefined, 500) + }, + // wait for AlertBox to be closed before wrapper is loaded. If no consented groups, do not load Segment. + shouldLoadSegment: async () => { await resolveWhen(() => { const oneTrustGlobal = getOneTrustGlobal() return ( - oneTrustGlobal !== undefined && Boolean(getConsentedGroupIds().length) && - oneTrustGlobal.IsAlertBoxClosed() + oneTrustGlobal!.IsAlertBoxClosed() ) }, 500) }, diff --git a/packages/consent/consent-wrapper-onetrust/src/test-helpers/mocks.ts b/packages/consent/consent-wrapper-onetrust/src/test-helpers/mocks.ts index 32640d75b..b19859eb4 100644 --- a/packages/consent/consent-wrapper-onetrust/src/test-helpers/mocks.ts +++ b/packages/consent/consent-wrapper-onetrust/src/test-helpers/mocks.ts @@ -1,5 +1,5 @@ import { OneTrustGlobal } from '../lib/onetrust-api' -import { throwNotImplemented } from './utils' +import { addMockImplementation } from './utils' import type { AnyAnalytics } from '@segment/analytics-consent-tools' /** * This can be used to mock the OneTrust global object in individual tests @@ -10,14 +10,17 @@ import type { AnyAnalytics } from '@segment/analytics-consent-tools' * ```` */ export const OneTrustMockGlobal: jest.Mocked = { - GetDomainData: jest.fn().mockImplementation(throwNotImplemented), - IsAlertBoxClosed: jest.fn().mockImplementation(throwNotImplemented), - OnConsentChanged: jest.fn().mockImplementation(throwNotImplemented), // not implemented atm + GetDomainData: jest.fn(), + IsAlertBoxClosed: jest.fn(), + OnConsentChanged: jest.fn(), } export const analyticsMock: jest.Mocked = { - addSourceMiddleware: jest.fn().mockImplementation(throwNotImplemented), - load: jest.fn().mockImplementation(throwNotImplemented), - on: jest.fn().mockImplementation(throwNotImplemented), - track: jest.fn().mockImplementation(throwNotImplemented), + addSourceMiddleware: jest.fn(), + load: jest.fn(), + on: jest.fn(), + track: jest.fn(), } + +addMockImplementation(OneTrustMockGlobal) +addMockImplementation(analyticsMock) diff --git a/packages/consent/consent-wrapper-onetrust/src/test-helpers/utils.ts b/packages/consent/consent-wrapper-onetrust/src/test-helpers/utils.ts index dca42f3ae..210da830b 100644 --- a/packages/consent/consent-wrapper-onetrust/src/test-helpers/utils.ts +++ b/packages/consent/consent-wrapper-onetrust/src/test-helpers/utils.ts @@ -1,3 +1,10 @@ -export const throwNotImplemented = (): never => { - throw new Error('not implemented') +export const addMockImplementation = (mock: jest.Mocked) => { + Object.entries(mock).forEach(([method, value]) => { + // automatically add mock implementation for debugging purposes + if (typeof value === 'function') { + mock[method] = mock[method].mockImplementation((args: any) => { + throw new Error(`Not Implemented: ${method}(${JSON.stringify(args)})`) + }) + } + }) }