diff --git a/.lintstagedrc.yaml b/.lintstagedrc.yaml index db4289fc..ab532150 100644 --- a/.lintstagedrc.yaml +++ b/.lintstagedrc.yaml @@ -1,5 +1,5 @@ '*.{js,jsx,cjs,mjs,ts,tsx}': - - eslint --fix + - eslint --fix --no-warn-ignored - prettier --check --write '*.{json,yaml,yml,md,mdx,mdc,html,css,less}': - prettier --check --write diff --git a/.specify/research/repository-research.md b/.specify/research/repository-research.md index ad9f2499..a8927305 100644 --- a/.specify/research/repository-research.md +++ b/.specify/research/repository-research.md @@ -129,11 +129,13 @@ graph LR - lifecycle interceptor managers (`event`, `state`) - `CoreBase` exposes high-level methods: - personalization path: `identify`, `page`, `screen`, `track` + - personalization resolution path: `getFlag`, `personalizeEntry`, `getMergeTagValue` - mixed path: `trackView` (sticky routes through personalization; non-sticky routes through analytics) - analytics path: `trackClick`, `trackHover`, `trackFlagView` - Resolver utilities are centralized and surfaced through core methods: - - flags resolution + - key-scoped flag resolution (`getFlag`) + - advanced full-map flag resolution through `flagsResolver.resolve(...)` - personalized entry resolution - merge-tag value resolution @@ -142,11 +144,14 @@ graph LR - `CoreStateless`: - no state signal model - composes `AnalyticsStateless` + `PersonalizationStateless` + - `getFlag(...)` does not auto-emit `trackFlagView` - intended for server/SSR usage - `CoreStateful`: - module-global signal model (`@preact/signals-core`) for `consent`, `profile`, `changes`, `selectedPersonalizations`, `event`, `blockedEvent`, `online`, `previewPanelAttached`, `previewPanelOpen` + - key-scoped reactive flag access via `states.flag(name)` + - `getFlag(...)` and `states.flag(name)` auto-emit flag-view analytics - singleton lock enforced via `StatefulRuntimeSingleton` keyed in `globalThis` - explicit lifecycle controls: `destroy()`, `flush()`, `reset()` - preview bridge method `registerPreviewPanel(...)` exposes mutable `signals` and `signalFns` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aeefb4c4..8fa46e58 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -174,6 +174,13 @@ automatically with each new version. - `pnpm docs:watch` watches for file updates and rebuilds documentation output; useful while writing and updating documentation +When changing public SDK behavior in this pre-release alpha period, update the same pull request to +keep these artifacts aligned: + +- TSDoc/JSDoc comments near changed API surfaces +- package READMEs that document those surfaces +- SpecKit artifacts under `specs/**` and `.specify/research/**` + ## Troubleshooting CI Issues ### E2E Coverage and Environment diff --git a/implementations/web-sdk/e2e/events.spec.ts b/implementations/node-sdk+web-sdk/e2e/entry-view-tracking.spec.ts similarity index 95% rename from implementations/web-sdk/e2e/events.spec.ts rename to implementations/node-sdk+web-sdk/e2e/entry-view-tracking.spec.ts index a7a1a104..a372e4ff 100644 --- a/implementations/web-sdk/e2e/events.spec.ts +++ b/implementations/node-sdk+web-sdk/e2e/entry-view-tracking.spec.ts @@ -16,7 +16,7 @@ const variantEntryTexts: Record = { 'This is a baseline content entry for all identified or unidentified users.', } -test.describe('events', () => { +test.describe('entry view tracking', () => { test.describe('without consent', () => { test.beforeEach(async ({ page }) => { await page.clock.install({ time: new Date() }) @@ -78,7 +78,9 @@ test.describe('events', () => { ).toBeVisible() } - const allComponentEvents = page.locator('#event-stream li button[data-view-id]') + const allComponentEvents = page.locator( + '#event-stream li button[data-view-id]:not([data-component-id="boolean"])', + ) await expect.poll(async () => await allComponentEvents.count()).toBeGreaterThanOrEqual(10) diff --git a/implementations/node-sdk+web-sdk/e2e/flag-view-tracking.spec.ts b/implementations/node-sdk+web-sdk/e2e/flag-view-tracking.spec.ts new file mode 100644 index 00000000..9d4a2047 --- /dev/null +++ b/implementations/node-sdk+web-sdk/e2e/flag-view-tracking.spec.ts @@ -0,0 +1,33 @@ +import { expect, test, type Locator, type Page } from '@playwright/test' + +function getFlagAccessComponentEvents(page: Page): Locator { + return page + .locator('#event-stream li button[data-component-id="boolean"]') + .filter({ hasText: /^component$/ }) +} + +test.describe('flag view tracking', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('domcontentloaded') + await page.getByRole('button', { name: 'Accept Consent' }).click() + await expect(page.getByRole('button', { name: 'Reject Consent' })).toBeVisible() + }) + + test('flag access emits a component event', async ({ page }) => { + await page.goto('/user/flag-access-e2e') + await page.waitForLoadState('domcontentloaded') + + const flagAccessEvents = getFlagAccessComponentEvents(page) + await expect + .poll(async () => await flagAccessEvents.count(), { + message: 'flag access should append a component event in the event stream', + }) + .toBeGreaterThan(0) + + const latestFlagAccessEvent = flagAccessEvents.last() + + await expect(latestFlagAccessEvent).toHaveText('component') + expect(await latestFlagAccessEvent.getAttribute('data-view-id')).toBeNull() + }) +}) diff --git a/implementations/node-sdk+web-sdk/src/index.ejs b/implementations/node-sdk+web-sdk/src/index.ejs index 043924b8..7fd06e80 100644 --- a/implementations/node-sdk+web-sdk/src/index.ejs +++ b/implementations/node-sdk+web-sdk/src/index.ejs @@ -517,6 +517,14 @@ void scheduleRenderEntries() }) + + contentfulOptimization.states.profile.subscribe((profile) => { + if (!profile) return + + // Emit and log flag access + const booleanFlag = contentfulOptimization.getFlag('boolean') + console.log('Flag "boolean" value:', booleanFlag) + }) diff --git a/implementations/node-sdk/src/app.ts b/implementations/node-sdk/src/app.ts index a67a4e12..e0ca1787 100644 --- a/implementations/node-sdk/src/app.ts +++ b/implementations/node-sdk/src/app.ts @@ -168,7 +168,7 @@ app.get('/', limiter, async (req, res) => { }) } - const { profile, selectedPersonalizations, changes } = optimizationResponse ?? {} + const { profile, selectedPersonalizations } = optimizationResponse ?? {} const personalizedEntries = new Map< string, @@ -205,13 +205,10 @@ app.get('/', limiter, async (req, res) => { personalizedEntries.set(entryId, personalizedEntry) }) - const flags = sdk.getCustomFlags(changes) - const pageData = { profile, selectedPersonalizations, entries: personalizedEntries, - flags, } res.render('index', { ...pageData }) diff --git a/implementations/react-native-sdk/App.tsx b/implementations/react-native-sdk/App.tsx index 7b5a9471..7f8245f4 100644 --- a/implementations/react-native-sdk/App.tsx +++ b/implementations/react-native-sdk/App.tsx @@ -32,6 +32,8 @@ function AppContent(): React.JSX.Element { const sdk = useOptimization() const [entries, setEntries] = useState([]) const [sdkError, setSdkError] = useState(null) + const [hasConsent, setHasConsent] = useState(false) + const [hasProfile, setHasProfile] = useState(false) const [isIdentified, setIsIdentified] = useState(false) const [showNavigationTest, setShowNavigationTest] = useState(false) const [showLiveUpdatesTest, setShowLiveUpdatesTest] = useState(false) @@ -41,6 +43,8 @@ function AppContent(): React.JSX.Element { void sdk.page({ properties: { url: 'app' } }) const subscription = sdk.states.profile.subscribe((profile) => { + setHasProfile(profile !== undefined) + if (!profile) { return } @@ -48,11 +52,28 @@ function AppContent(): React.JSX.Element { void fetchEntries(ENTRY_IDS, setEntries, setSdkError) }) + const consentSubscription = sdk.states.consent.subscribe((consent) => { + setHasConsent(consent === true) + }) + return () => { subscription.unsubscribe() + consentSubscription.unsubscribe() } }, [sdk]) + useEffect(() => { + if (!hasConsent || !hasProfile) { + return + } + + const flagSubscription = sdk.states.flag('boolean').subscribe(() => undefined) + + return () => { + flagSubscription.unsubscribe() + } + }, [hasConsent, hasProfile, sdk]) + const handleIdentify = (): void => { void sdk.identify({ userId: 'charles', traits: { identified: true } }) setIsIdentified(true) diff --git a/implementations/react-native-sdk/e2e/flag-view-tracking.test.js b/implementations/react-native-sdk/e2e/flag-view-tracking.test.js new file mode 100644 index 00000000..bc95cd4b --- /dev/null +++ b/implementations/react-native-sdk/e2e/flag-view-tracking.test.js @@ -0,0 +1,22 @@ +const { + clearProfileState, + ELEMENT_VISIBILITY_TIMEOUT, + waitForComponentEventCount, +} = require('./helpers') + +describe('Flag View Tracking', () => { + beforeAll(async () => { + await device.launchApp() + }) + + beforeEach(async () => { + await clearProfileState({ requireFreshAppInstance: true }) + }) + + it('should emit component events for the subscribed boolean flag', async () => { + const analyticsTitle = element(by.text('Analytics Events')) + await waitFor(analyticsTitle).toBeVisible().withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + + await waitForComponentEventCount('boolean', 1, ELEMENT_VISIBILITY_TIMEOUT) + }) +}) diff --git a/implementations/web-sdk/e2e/entry-view-tracking.spec.ts b/implementations/web-sdk/e2e/entry-view-tracking.spec.ts new file mode 100644 index 00000000..a372e4ff --- /dev/null +++ b/implementations/web-sdk/e2e/entry-view-tracking.spec.ts @@ -0,0 +1,101 @@ +import { expect, test } from '@playwright/test' + +const variantEntryTexts: Record = { + '1JAU028vQ7v6nB2swl3NBo': 'This is a level 0 nested baseline entry.', + '5i4SdJXw9oDEY0vgO7CwF4': 'This is a level 1 nested baseline entry.', + uaNY4YJ0HFPAX3gKXiRdX: 'This is a level 2 nested baseline entry.', + '1MwiFl4z7gkwqGYdvCmr8c': + 'This is a merge tag content entry that displays the visitor\'s continent "EU" embedded within the text.', + '4k6ZyFQnR2POY5IJLLlJRb': 'This is a variant content entry for visitors from Europe.', + '6iyPl6vfDH5AoClf3MtYlh': 'This is a variant content entry for visitors using a desktop browser.', + '1UFf7qr4mHET3HYuYmcpEj': 'This is a variant content entry for new visitors.', + '4bmHsNUaEibELHwWCon3dt': 'This is a variant content entry for an A/B/C experiment: B', + '6zqoWXyiSrf0ja7I2WGtYj': + 'This is a baseline content entry for all visitors with or without a custom event.', + '7pa5bOx8Z9NmNcr7mISvD': + 'This is a baseline content entry for all identified or unidentified users.', +} + +test.describe('entry view tracking', () => { + test.describe('without consent', () => { + test.beforeEach(async ({ page }) => { + await page.clock.install({ time: new Date() }) + await page.goto('/') + await page.waitForLoadState('domcontentloaded') + }) + + test('page event has been emitted', async ({ page }) => { + await expect( + page.getByRole('listitem').filter({ has: page.getByRole('button', { name: 'page' }) }), + ).toBeVisible() + }) + + test('component view events have not been emitted', async ({ page }) => { + for (const entryText of Object.values(variantEntryTexts)) { + const element = page.getByText(entryText) + + await element.scrollIntoViewIfNeeded() + + await page.clock.fastForward('02:00') + } + + await expect(page.locator('#event-stream li button[data-view-id]')).toHaveCount(0) + }) + }) + + test.describe('with consent', () => { + test.beforeEach(async ({ page }) => { + await page.clock.install({ time: new Date() }) + await page.goto('/') + await page.waitForLoadState('domcontentloaded') + + const consent = page.getByRole('button', { name: 'Accept Consent' }) + await consent.click() + }) + + test('page event has been emitted', async ({ page }) => { + await expect( + page.getByRole('listitem').filter({ has: page.getByRole('button', { name: 'page' }) }), + ).toBeVisible() + }) + + test('component view events have been emitted', async ({ page }) => { + for (const entryId of Object.keys(variantEntryTexts)) { + const entryText = variantEntryTexts[entryId] + + if (!entryText) continue + + const element = page.getByText(entryText) + + await element.scrollIntoViewIfNeeded() + + await page.clock.fastForward('02:00') + + await expect( + page + .locator(`#event-stream li button[data-component-id="${entryId}"][data-view-id]`) + .first(), + ).toBeVisible() + } + + const allComponentEvents = page.locator( + '#event-stream li button[data-view-id]:not([data-component-id="boolean"])', + ) + + await expect.poll(async () => await allComponentEvents.count()).toBeGreaterThanOrEqual(10) + + const viewIdButtons = allComponentEvents + const viewIds: string[] = [] + const viewIdCount = await viewIdButtons.count() + + for (let index = 0; index < viewIdCount; index += 1) { + const viewId = await viewIdButtons.nth(index).getAttribute('data-view-id') + + expect(viewId).not.toBeNull() + if (viewId) viewIds.push(viewId) + } + + expect(new Set(viewIds).size).toEqual(viewIds.length) + }) + }) +}) diff --git a/implementations/web-sdk/e2e/flag-view-tracking.spec.ts b/implementations/web-sdk/e2e/flag-view-tracking.spec.ts new file mode 100644 index 00000000..37319990 --- /dev/null +++ b/implementations/web-sdk/e2e/flag-view-tracking.spec.ts @@ -0,0 +1,34 @@ +import { expect, test, type Locator, type Page } from '@playwright/test' + +function getFlagAccessComponentEvents(page: Page): Locator { + return page + .locator('#event-stream li button[data-component-id="boolean"]') + .filter({ hasText: /^component$/ }) +} + +test.describe('flag view tracking', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('domcontentloaded') + await page.getByRole('button', { name: 'Accept Consent' }).click() + }) + + test('flag access emits a component event', async ({ page }) => { + const flagAccessEvents = getFlagAccessComponentEvents(page) + const baselineFlagEventCount = await flagAccessEvents.count() + + await page.getByRole('button', { name: 'Identify' }).click() + await expect(page.getByRole('button', { name: 'Reset Profile' })).toBeVisible() + + await expect + .poll(async () => await flagAccessEvents.count(), { + message: 'flag access should append a component event in the event stream', + }) + .toBeGreaterThan(baselineFlagEventCount) + + const latestFlagAccessEvent = flagAccessEvents.last() + + await expect(latestFlagAccessEvent).toHaveText('component') + expect(await latestFlagAccessEvent.getAttribute('data-view-id')).toBeNull() + }) +}) diff --git a/implementations/web-sdk/public/index.html b/implementations/web-sdk/public/index.html index 951855ee..a2210662 100644 --- a/implementations/web-sdk/public/index.html +++ b/implementations/web-sdk/public/index.html @@ -576,10 +576,13 @@

Event Stream

}, ) - // Subscribe to profile state, find entries in the markup, and render them contentfulOptimization.states.profile.subscribe((profile) => { if (!profile) return + // Emit and log flag access + const booleanFlag = contentfulOptimization.getFlag('boolean') + console.log('Flag "boolean" value:', booleanFlag) + toggleIdentity(profile.traits && Object.keys(profile.traits).length) }) diff --git a/implementations/web-sdk_react/e2e/flag-view-tracking.spec.ts b/implementations/web-sdk_react/e2e/flag-view-tracking.spec.ts new file mode 100644 index 00000000..d2a35ba5 --- /dev/null +++ b/implementations/web-sdk_react/e2e/flag-view-tracking.spec.ts @@ -0,0 +1,44 @@ +import { expect, test } from '@playwright/test' + +test.describe('flag view tracking', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('domcontentloaded') + await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() + }) + + test('does not emit flag component events without consent', async ({ page }) => { + const flagEvents = page.locator('[data-testid="event-component-boolean"]') + + await expect(flagEvents).toHaveCount(0) + + await page.getByTestId('live-updates-identify-button').click() + await expect(page.getByTestId('live-updates-reset-button')).toBeVisible() + + await expect(flagEvents).toHaveCount(0) + }) + + test('emits flag component events after consent and profile updates', async ({ page }) => { + const flagEvents = page.locator('[data-testid="event-component-boolean"]') + const baselineFlagEventCount = await flagEvents.count() + + await page.getByTestId('consent-button').click() + + await expect + .poll(async () => await flagEvents.count(), { + message: 'consented flag subscription should emit a component event', + }) + .toBeGreaterThan(baselineFlagEventCount) + + const afterConsentFlagEventCount = await flagEvents.count() + + await page.getByTestId('live-updates-identify-button').click() + await expect(page.getByTestId('live-updates-reset-button')).toBeVisible() + + await expect + .poll(async () => await flagEvents.count(), { + message: 'profile updates should emit additional flag component events', + }) + .toBeGreaterThan(afterConsentFlagEventCount) + }) +}) diff --git a/implementations/web-sdk_react/src/App.tsx b/implementations/web-sdk_react/src/App.tsx index fa60c644..5ba10e49 100644 --- a/implementations/web-sdk_react/src/App.tsx +++ b/implementations/web-sdk_react/src/App.tsx @@ -43,22 +43,34 @@ export default function App({ onToggleGlobalLiveUpdates, }: AppProps): JSX.Element { const location = useLocation() - const { sdk, isReady, error } = useOptimization() + const { sdk, error } = useOptimization() const { consent, profile, selectedPersonalizations } = useOptimizationState(sdk?.states) const [entries, setEntries] = useState([]) const [entriesError, setEntriesError] = useState(null) useEffect(() => { - if (!isReady || sdk === undefined) { + if (sdk === undefined) { return } void sdk.page({ properties: { url: location.pathname } }) - }, [isReady, location.pathname, sdk]) + }, [location.pathname, sdk]) useEffect(() => { - if (!isReady || sdk === undefined) { + if (sdk === undefined || consent !== true || profile === undefined) { + return + } + + const subscription = sdk.states.flag('boolean').subscribe(() => undefined) + + return () => { + subscription.unsubscribe() + } + }, [consent, profile?.id, sdk]) + + useEffect(() => { + if (sdk === undefined) { return } @@ -82,7 +94,7 @@ export default function App({ fetchError instanceof Error ? fetchError.message : 'Unknown entry load error' setEntriesError(message) }) - }, [isReady, sdk]) + }, [sdk]) const isIdentified = useMemo(() => isIdentifiedProfile(profile), [profile]) const entriesById = useMemo(() => toEntryMap(entries), [entries]) @@ -94,35 +106,11 @@ export default function App({ const hasPageTwoEntries = entriesById.has(PAGE_TWO_AUTO_ENTRY_ID) && entriesById.has(PAGE_TWO_MANUAL_ENTRY_ID) - const handleIdentify = (): void => { - if (!isReady || sdk === undefined) { - return - } - - void sdk.identify({ userId: 'charles', traits: { identified: true } }) - } - - const handleReset = (): void => { - if (!isReady || sdk === undefined) { - return - } - - sdk.reset() - } - - const handleConsent = (accepted: boolean): void => { - if (!isReady || sdk === undefined) { - return - } - - sdk.consent(accepted) - } - if (error) { return

{error.message}

} - if (!isReady || sdk === undefined) { + if (sdk === undefined) { return

Loading SDK...

} @@ -142,6 +130,18 @@ export default function App({ return

Page Two demo entries are missing from fetched entries.

} + const handleIdentify = (): void => { + void sdk.identify({ userId: 'charles', traits: { identified: true } }) + } + + const handleReset = (): void => { + sdk.reset() + } + + const handleConsent = (accepted: boolean): void => { + sdk.consent(accepted) + } + return (