@for (id of manualIds; track id) { @let entry = entryFor(id); @if (entry) {
diff --git a/implementations/web-sdk_angular/src/app/pages/page-two/index.html b/implementations/web-sdk_angular/src/app/pages/page-two/index.html
index bfc61be61..798f20feb 100644
--- a/implementations/web-sdk_angular/src/app/pages/page-two/index.html
+++ b/implementations/web-sdk_angular/src/app/pages/page-two/index.html
@@ -1,32 +1,35 @@
-@if (entries.isLoading()) {
-
Loading entriesβ¦
-} @else {
-
+
+
-
+
-
- @if (autoEntry(); as entry) {
-
- } @if (manualEntry(); as entry) {
-
+ @if (entries.isLoading()) {
+
Loading entriesβ¦
+ } @else {
+
+ @if (autoEntry(); as entry) {
+
+ } @if (manualEntry(); as entry) {
+
+ }
+
}
-}
diff --git a/implementations/web-sdk_angular/src/app/pages/page-two/index.ts b/implementations/web-sdk_angular/src/app/pages/page-two/index.ts
index a2563ab3b..f44c3a452 100644
--- a/implementations/web-sdk_angular/src/app/pages/page-two/index.ts
+++ b/implementations/web-sdk_angular/src/app/pages/page-two/index.ts
@@ -1,4 +1,5 @@
import { Component, inject } from '@angular/core'
+import { RouterLink } from '@angular/router'
import { ControlPanel } from '../../components/control-panel'
import { EntryCard } from '../../components/entry-card'
import { FIXTURES } from '../../fixtures'
@@ -10,7 +11,7 @@ const PAGE_TWO_COMPONENT_ID = 'page-two-conversion'
@Component({
selector: 'app-page-two',
- imports: [EntryCard, ControlPanel],
+ imports: [EntryCard, ControlPanel, RouterLink],
templateUrl: './index.html',
host: { style: 'display: contents' },
})
diff --git a/implementations/web-sdk_angular/src/app/services/entry.ts b/implementations/web-sdk_angular/src/app/services/entry.ts
index 8d68a6179..613119cee 100644
--- a/implementations/web-sdk_angular/src/app/services/entry.ts
+++ b/implementations/web-sdk_angular/src/app/services/entry.ts
@@ -110,7 +110,12 @@ export function injectContentfulEntry({
const optimization = inject(NgContentfulOptimization)
function liveRead
(sig: Signal): T {
- return isLive() ? sig() : untracked(sig)
+ if (isLive()) return sig()
+ // untracked(sig) would snapshot undefined before the SDK responds, permanently
+ // freezing the computed. The tracked fallback keeps reactivity alive only until
+ // the first real value arrives; after that untracked(sig) is non-null and the
+ // reactive dependency is never taken.
+ return untracked(sig) ?? sig()
}
const variant = computed(() => {
diff --git a/implementations/web-sdk_angular/src/app/services/live-updates.ts b/implementations/web-sdk_angular/src/app/services/live-updates.ts
index 55a9aad15..b8dc44180 100644
--- a/implementations/web-sdk_angular/src/app/services/live-updates.ts
+++ b/implementations/web-sdk_angular/src/app/services/live-updates.ts
@@ -2,6 +2,12 @@ import { computed, inject, Injectable, signal } from '@angular/core'
import { fromSdkState } from '../utils'
import { NgContentfulOptimization } from './optimization'
+function clickPreviewPanelToggle(): void {
+ const panel = document.querySelector('ctfl-opt-preview-panel')
+ const btn = panel?.shadowRoot?.querySelector('button.toggle-drawer')
+ btn?.click()
+}
+
@Injectable({ providedIn: 'root' })
export class NgLiveUpdates {
private readonly sdk = inject(NgContentfulOptimization).sdk
@@ -20,4 +26,6 @@ export class NgLiveUpdates {
toggle(): void {
this.globalLiveUpdatesSignal.update((v) => !v)
}
+
+ readonly togglePreviewPanel = clickPreviewPanelToggle
}
diff --git a/implementations/web-sdk_angular/src/app/services/optimization.ts b/implementations/web-sdk_angular/src/app/services/optimization.ts
index 03c1665e3..912f224f0 100644
--- a/implementations/web-sdk_angular/src/app/services/optimization.ts
+++ b/implementations/web-sdk_angular/src/app/services/optimization.ts
@@ -1,8 +1,7 @@
-import { computed, inject, Injectable, type OnDestroy, type Signal } from '@angular/core'
+import { inject, Injectable, type OnDestroy, type Signal } from '@angular/core'
import { NavigationEnd, Router } from '@angular/router'
import ContentfulOptimization from '@contentful/optimization-web'
-import type { SelectedOptimizationArray } from '@contentful/optimization-web/api-schemas'
-import { Profile } from '@contentful/optimization-web/api-schemas'
+import type { Profile, SelectedOptimizationArray } from '@contentful/optimization-web/api-schemas'
import type { Subscription } from 'rxjs'
import { filter } from 'rxjs/operators'
import type { NgContentfulOptimizationConfig } from '../config'
@@ -81,13 +80,7 @@ export class NgContentfulOptimization implements OnDestroy {
this.consent = fromSdkState(this.sdk.states.consent)
- const rawProfile = fromSdkState(this.sdk.states.profile)
- this.profile = computed(() => {
- const result = Profile.safeParse(rawProfile())
- if (!result.success) return undefined
- // anonymous profiles exist after reset β only expose when the user is identified
- return result.data.traits.identified ? result.data : undefined
- })
+ this.profile = fromSdkState(this.sdk.states.profile)
this.selectedOptimizations = fromSdkState(this.sdk.states.selectedOptimizations)
diff --git a/implementations/web-sdk_angular/src/app/utils.ts b/implementations/web-sdk_angular/src/app/utils.ts
index bad4b0976..2ad884c65 100644
--- a/implementations/web-sdk_angular/src/app/utils.ts
+++ b/implementations/web-sdk_angular/src/app/utils.ts
@@ -1,4 +1,4 @@
-import { DestroyRef, inject, signal, type Signal } from '@angular/core'
+import { DestroyRef, effect, inject, signal, type Signal } from '@angular/core'
export function isRecord(value: unknown): value is Record {
return typeof value === 'object' && value !== null
@@ -8,14 +8,35 @@ export interface SdkObservable {
subscribe: (fn: (v: T) => void) => { unsubscribe: () => void }
}
-export function fromSdkState(obs: SdkObservable): Signal {
+export function fromSdkState(
+ source: SdkObservable | (() => SdkObservable | undefined),
+): Signal {
const s = signal(undefined)
const destroyRef = inject(DestroyRef)
- const sub = obs.subscribe((v) => {
- s.set(v)
- })
- destroyRef.onDestroy(() => {
- sub.unsubscribe()
- })
+
+ if (typeof source === 'function') {
+ let sub: { unsubscribe: () => void } | undefined = undefined
+
+ effect(() => {
+ sub?.unsubscribe()
+ sub = undefined
+ s.set(undefined)
+ const obs = source()
+ if (obs)
+ sub = obs.subscribe((v) => {
+ s.set(v)
+ })
+ })
+
+ destroyRef.onDestroy(() => sub?.unsubscribe())
+ } else {
+ const sub = source.subscribe((v) => {
+ s.set(v)
+ })
+ destroyRef.onDestroy(() => {
+ sub.unsubscribe()
+ })
+ }
+
return s.asReadonly()
}
diff --git a/implementations/web-sdk_angular/src/styles.css b/implementations/web-sdk_angular/src/styles.css
index 0878faa42..fb9ac7801 100644
--- a/implementations/web-sdk_angular/src/styles.css
+++ b/implementations/web-sdk_angular/src/styles.css
@@ -144,11 +144,15 @@ nav a.active {
main {
grid-area: main;
min-width: 0;
- padding: 1.5rem 24rem 3rem 1.5rem;
+ padding: 1.5rem 1.5rem 3rem;
display: grid;
gap: 2.5rem;
}
+main.preview-panel-open {
+ padding-right: 28rem;
+}
+
.sections-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
diff --git a/implementations/web-sdk_react/.env.example b/implementations/web-sdk_react/.env.example
index 9273d7d65..d31cd0925 100644
--- a/implementations/web-sdk_react/.env.example
+++ b/implementations/web-sdk_react/.env.example
@@ -13,4 +13,4 @@ PUBLIC_CONTENTFUL_SPACE_ID="mock-space-id"
PUBLIC_CONTENTFUL_CDA_HOST="localhost:8000"
PUBLIC_CONTENTFUL_BASE_PATH="contentful"
-PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL="false"
+PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL="true"
diff --git a/implementations/web-sdk_react/e2e/flag-view-tracking.spec.ts b/implementations/web-sdk_react/e2e/flag-view-tracking.spec.ts
deleted file mode 100644
index f5702b24d..000000000
--- a/implementations/web-sdk_react/e2e/flag-view-tracking.spec.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-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 view 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 view 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 flag view 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 view events',
- })
- .toBeGreaterThan(afterConsentFlagEventCount)
- })
-})
diff --git a/implementations/web-sdk_react/package.json b/implementations/web-sdk_react/package.json
index 0c57dc0e2..a376a3e99 100644
--- a/implementations/web-sdk_react/package.json
+++ b/implementations/web-sdk_react/package.json
@@ -11,18 +11,15 @@
"clean": "rimraf ./.rsdoctor ./.rslib ./dist ./coverage ./playwright-report ./test-results .tsbuildinfo",
"preview": "pnpm serve:mocks && rsbuild preview",
"serve": "pnpm serve:mocks && pnpm serve:app",
- "serve:app": "pnpm build && pm2 start --name web-sdk_react-app \"pnpm preview\"",
+ "serve:e2e": "rsbuild build && rsbuild preview",
+ "serve:app": "pnpm build && pm2 start --name web-sdk_react-app \"rsbuild preview\"",
"serve:app:stop": "pm2 stop web-sdk_react-app && pm2 delete web-sdk_react-app",
"serve:mocks": "pm2 start --name web-sdk_react-mocks \"pnpm --dir ../../lib/mocks serve\"",
"serve:mocks:stop": "pm2 stop web-sdk_react-mocks && pm2 delete web-sdk_react-mocks",
"serve:stop": "pnpm serve:app:stop && pnpm serve:mocks:stop",
- "test:e2e": "pnpm serve && playwright test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT",
- "test:e2e:codegen": "playwright codegen",
- "test:e2e:report": "playwright show-report",
- "test:e2e:ui": "playwright test --ui",
- "implementation:playwright:install": "playwright install",
- "implementation:playwright:install-deps": "playwright install-deps",
- "implementation:setup:e2e": "pnpm implementation:playwright:install && pnpm implementation:playwright:install-deps",
+ "test:e2e": "IMPLEMENTATION=web-sdk_react pnpm --dir ../../lib/e2e-web test",
+ "test:e2e:ui": "IMPLEMENTATION=web-sdk_react pnpm --dir ../../lib/e2e-web test:ui",
+ "test:e2e:report": "pnpm --dir ../../lib/e2e-web test:report",
"test:unit": "echo \"No unit tests necessary\"",
"typecheck": "tsc --noEmit"
},
@@ -37,7 +34,6 @@
"react-router-dom": "7.14.1"
},
"devDependencies": {
- "@playwright/test": "1.58.2",
"@rsbuild/core": "1.7.3",
"@rsbuild/plugin-react": "1.4.5",
"@rsdoctor/cli": "1.5.2",
diff --git a/implementations/web-sdk_react/playwright.config.mjs b/implementations/web-sdk_react/playwright.config.mjs
deleted file mode 100644
index 2bb706790..000000000
--- a/implementations/web-sdk_react/playwright.config.mjs
+++ /dev/null
@@ -1,83 +0,0 @@
-import { defineConfig, devices } from '@playwright/test'
-import dotenv from 'dotenv'
-import path from 'path'
-import { fileURLToPath } from 'url'
-
-const __filename = fileURLToPath(import.meta.url)
-const __dirname = path.dirname(__filename)
-dotenv.config({ path: path.resolve(__dirname, '.env') })
-
-const isCI = Boolean(process.env.CI)
-
-/**
- * See https://playwright.dev/docs/test-configuration.
- */
-export default defineConfig({
- testDir: './e2e',
- /* Run tests in files in parallel */
- fullyParallel: true,
- /* Fail the build on CI if you accidentally left test.only in the source code. */
- forbidOnly: isCI,
- /* Retry on CI only */
- retries: isCI ? 2 : 0,
- /* Opt out of parallel tests on CI. */
- workers: isCI ? 1 : undefined,
- /* Reporter to use. See https://playwright.dev/docs/test-reporters */
- reporter: [['html', { open: 'never' }]],
- /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
- use: {
- /* Base URL to use in actions like `await page.goto('/')`. */
- baseURL: 'http://localhost:3000',
-
- /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
- trace: 'on-first-retry',
-
- /* Record video only when retrying a test for the first time. */
- video: 'on-first-retry',
- },
-
- /* Configure projects for major browsers */
- projects: [
- {
- name: 'chromium',
- use: { ...devices['Desktop Chrome'] },
- },
-
- {
- name: 'firefox',
- use: { ...devices['Desktop Firefox'] },
- },
-
- {
- name: 'webkit',
- use: { ...devices['Desktop Safari'] },
- },
-
- /* Test against mobile viewports. */
- // {
- // name: 'Mobile Chrome',
- // use: { ...devices['Pixel 5'] },
- // },
- // {
- // name: 'Mobile Safari',
- // use: { ...devices['iPhone 12'] },
- // },
-
- /* Test against branded browsers. */
- // {
- // name: 'Microsoft Edge',
- // use: { ...devices['Desktop Edge'], channel: 'msedge' },
- // },
- // {
- // name: 'Google Chrome',
- // use: { ...devices['Desktop Chrome'], channel: 'chrome' },
- // },
- ],
-
- /* Run your local dev server before starting the tests */
- // webServer: {
- // command: 'pnpm --filter e2e serve',
- // url: 'http://localhost',
- // reuseExistingServer: !isCI,
- // },
-})
diff --git a/implementations/web-sdk_react/src/App.tsx b/implementations/web-sdk_react/src/App.tsx
index 009e16632..8bd81d048 100644
--- a/implementations/web-sdk_react/src/App.tsx
+++ b/implementations/web-sdk_react/src/App.tsx
@@ -143,44 +143,69 @@ export default function App({
}
return (
-
-
+
+
Home
- Go to Page Two
+ Page Two
-
-
+
+
+
+
+ }
/>
- }
- />
-
- }
- />
- } />
-
-
-
-
+
+ }
+ />
+ } />
+
+
+
+
)
}
diff --git a/implementations/web-sdk_react/src/components/AnalyticsEventDisplay.tsx b/implementations/web-sdk_react/src/components/AnalyticsEventDisplay.tsx
index 1a18b1da6..b6c9ecb4e 100644
--- a/implementations/web-sdk_react/src/components/AnalyticsEventDisplay.tsx
+++ b/implementations/web-sdk_react/src/components/AnalyticsEventDisplay.tsx
@@ -1,10 +1,24 @@
-import { type JSX, useEffect, useRef, useState } from 'react'
+import { type JSX, useEffect, useReducer, useRef, useState } from 'react'
import { useOptimization } from '../optimization/hooks/useOptimization'
import { isRecord } from '../utils/typeGuards'
+const MS_PER_SECOND = 1000
+const SECONDS_PER_MINUTE = 60
+const MINUTES_PER_HOUR = 60
+const TICK_INTERVAL_SECONDS = 5
+
+function timeAgo(firedAt: number): string {
+ const s = Math.floor((Date.now() - firedAt) / MS_PER_SECOND)
+ if (s < SECONDS_PER_MINUTE) return `${s}s`
+ const m = Math.floor(s / SECONDS_PER_MINUTE)
+ if (m < MINUTES_PER_HOUR) return `${m}m`
+ return `${Math.floor(m / MINUTES_PER_HOUR)}h`
+}
+
interface AnalyticsEvent {
id: string
componentId?: string
+ firedAt: number
hoverId?: string
viewId?: string
hoverDurationMs?: number
@@ -48,6 +62,7 @@ function toAnalyticsEvent(event: unknown, id: string): AnalyticsEvent | undefine
return {
id,
componentId,
+ firedAt: Date.now(),
hoverId,
viewId,
hoverDurationMs,
@@ -102,17 +117,58 @@ function upsertAnalyticsEvent(
}
const updated = [...previous]
- updated[existingIndex] = { ...nextEvent, id: previous[existingIndex].id }
+ updated[existingIndex] = {
+ ...nextEvent,
+ id: previous[existingIndex].id,
+ firedAt: previous[existingIndex].firedAt,
+ }
return updated
}
+const BADGE_COLORS: Record
= {
+ page: { background: '#dbeafe', color: '#1d4ed8' },
+ view: { background: '#dcfce7', color: '#15803d' },
+ comp: { background: '#d1fae5', color: '#065f46' },
+ hover: { background: '#f3e8ff', color: '#7e22ce' },
+}
+const BADGE_FALLBACK = { background: '#e5e7eb', color: '#374151' }
+
+function toBadgeType(event: AnalyticsEvent): string {
+ if (event.type === 'component') return event.viewId ? 'view' : 'comp'
+ if (event.type === 'component_hover') return 'hover'
+ return event.type
+}
+
+function toTestId(event: AnalyticsEvent): string {
+ if (event.viewId) return `event-view-${event.viewId}`
+ if (event.hoverId) return `event-${event.type}-hover-${event.hoverId}`
+ if (event.componentId) return `event-${event.type}-${event.componentId}`
+ return `event-${event.type}-${event.id}`
+}
+
+function toValue(event: AnalyticsEvent): string {
+ return event.componentId ?? event.pageUrl ?? event.type
+}
+
+function toDuration(event: AnalyticsEvent): number | undefined {
+ return event.hoverDurationMs ?? event.viewDurationMs
+}
+
export function AnalyticsEventDisplay(): JSX.Element {
const { sdk, isReady } = useOptimization()
const [events, setEvents] = useState([])
const [rawEventsCount, setRawEventsCount] = useState(0)
+ const [, tick] = useReducer((n: number) => n + 1, 0)
const nextId = useRef(0)
+ useEffect(() => {
+ const id = setInterval(tick, MS_PER_SECOND * TICK_INTERVAL_SECONDS)
+ return () => {
+ clearInterval(id)
+ }
+ }, [])
+
useEffect(() => {
if (!isReady || sdk === undefined) {
setEvents([])
@@ -140,38 +196,128 @@ export function AnalyticsEventDisplay(): JSX.Element {
return (
- Analytics Events
- Events: {events.length}
- Raw Events: {rawEventsCount}
+
+
Tracking
+
+ {rawEventsCount} events
+
+
{events.length === 0 ? No events tracked yet
: null}
-
- {events.map((event) => {
- const testId = event.viewId
- ? `event-view-${event.viewId}`
- : event.hoverId
- ? `event-${event.type}-hover-${event.hoverId}`
- : event.componentId
- ? `event-${event.type}-${event.componentId}`
- : `event-${event.type}-${event.id}`
-
- const label = event.componentId
- ? typeof event.viewDurationMs === 'number'
- ? `${event.type} - Entry/Flag: ${event.componentId} - Duration: ${event.viewDurationMs}ms`
- : typeof event.hoverDurationMs === 'number'
- ? `${event.type} - Entry/Flag: ${event.componentId} - Hover Duration: ${event.hoverDurationMs}ms`
- : `${event.type} - Entry/Flag: ${event.componentId}`
- : event.pageUrl
- ? `${event.type} - URL: ${event.pageUrl}`
- : event.type
-
- return (
-
- {label}
-
- )
- })}
-
+ {events.length > 0 ? (
+
+
+
+ {['Type', 'Value', 'Dur', 'Age', ''].map((h) => (
+
+ {h}
+
+ ))}
+
+
+
+ {events.map((event) => {
+ const badgeType = toBadgeType(event)
+ const badgeStyle = BADGE_COLORS[badgeType] ?? BADGE_FALLBACK
+ const duration = toDuration(event)
+
+ return (
+
+
+
+ {badgeType}
+
+
+
+ {toValue(event)}
+
+
+ {duration !== undefined ? `${(duration / MS_PER_SECOND).toFixed(1)}s` : null}
+
+
+ {timeAgo(event.firedAt)}
+
+
+
+ )
+ })}
+
+
+ ) : null}
)
}
diff --git a/implementations/web-sdk_react/src/optimization/hooks/useOptimizationResolver.ts b/implementations/web-sdk_react/src/optimization/hooks/useOptimizationResolver.ts
index c717ee0f9..b509097e1 100644
--- a/implementations/web-sdk_react/src/optimization/hooks/useOptimizationResolver.ts
+++ b/implementations/web-sdk_react/src/optimization/hooks/useOptimizationResolver.ts
@@ -6,6 +6,7 @@ import type { ResolvedData } from '@contentful/optimization-web/core-sdk'
import type { Entry, EntrySkeletonType } from 'contentful'
import { useMemo } from 'react'
import { useOptimization } from './useOptimization'
+import { useOptimizationState } from './useOptimizationState'
export interface UseOptimizationResolverResult {
resolveEntry: (
@@ -44,6 +45,10 @@ function toStringValue(value: unknown): string {
export function useOptimizationResolver(): UseOptimizationResolverResult {
const { sdk, isReady } = useOptimization()
+ // Subscribe to selectedOptimizations so resolveEntry gets a new identity when the
+ // Experience API responds. Without this, ContentEntry's useMemo would lock in the
+ // baseline on first render (signal still empty) and never re-resolve on slow browsers.
+ const { selectedOptimizations } = useOptimizationState(sdk?.states)
return useMemo(() => {
if (!isReady || sdk === undefined) {
@@ -56,12 +61,15 @@ export function useOptimizationResolver(): UseOptimizationResolverResult {
return {
resolveEntry: (
baselineEntry: Entry,
- selectedOptimizations?: SelectedOptimizationArray,
+ callerSelectedOptimizations?: SelectedOptimizationArray,
): ResolvedData =>
- sdk.resolveOptimizedEntry(baselineEntry, selectedOptimizations),
+ sdk.resolveOptimizedEntry(
+ baselineEntry,
+ callerSelectedOptimizations ?? selectedOptimizations,
+ ),
getMergeTagValue: (mergeTagEntry: MergeTagEntry): string =>
toStringValue(sdk.getMergeTagValue(mergeTagEntry)),
}
- }, [isReady, sdk])
+ }, [isReady, sdk, selectedOptimizations])
}
diff --git a/implementations/web-sdk_react/src/pages/HomePage.tsx b/implementations/web-sdk_react/src/pages/HomePage.tsx
index d05223de9..2130f41b8 100644
--- a/implementations/web-sdk_react/src/pages/HomePage.tsx
+++ b/implementations/web-sdk_react/src/pages/HomePage.tsx
@@ -24,6 +24,12 @@ function isConsentAccepted(consent: boolean | undefined): boolean {
return consent === true
}
+function consentLabel(consent: boolean | undefined): string {
+ if (consent === true) return 'Yes'
+ if (consent === false) return 'No'
+ return 'undefined'
+}
+
const AUTO_OBSERVED_CLICK_SCENARIO_BY_ENTRY_ID: Readonly> = {
'4ib0hsHWoSOnCVdDkizE8d': 'direct',
xFwgG3oNaOcjzWiGe4vXo: 'descendant',
@@ -50,7 +56,16 @@ export function HomePage({
Utilities
-
+
+ Consent
+ {consentLabel(consent)}
{isConsentAccepted(consent) ? (
- Reject Consent
+ Revoke
) : (
- Accept Consent
+ Grant
)}
+ Identified
+ {isIdentified ? 'Yes' : 'No'}
{!isIdentified ? (
-
+
Identify
) : (
-
- Reset Profile
+
+ Reset
)}
+ Live updates
+ {globalLiveUpdates ? 'ON' : 'OFF'}
- {`Global: ${globalLiveUpdates ? 'ON' : 'OFF'}`}
+ {globalLiveUpdates ? 'OFF' : 'ON'}
- {
- liveUpdatesContext?.togglePreviewPanel()
- }}
- type="button"
- >
- {isPreviewPanelOpen ? 'Close Preview Panel' : 'Open Preview Panel'}
-
+ {ENABLE_PREVIEW_PANEL ? (
+ <>
+ Preview panel
+
+ {isPreviewPanelOpen ? 'Open' : 'Closed'}
+
+ {
+ liveUpdatesContext?.togglePreviewPanel()
+ }}
+ type="button"
+ >
+ {isPreviewPanelOpen ? 'Close Preview Panel' : 'Open Preview Panel'}
+
+ >
+ ) : null}
+
+ Active optimizations
+ {selectedOptimizationCount}
+
-
-
Consent: {String(consent)}
-
- Selected Optimizations: {selectedOptimizationCount}
-
-
{isIdentified ? 'Yes' : 'No'}
-
{globalLiveUpdates ? 'ON' : 'OFF'}
-
{isPreviewPanelOpen ? 'Open' : 'Closed'}
@@ -118,31 +141,20 @@ export function HomePage({
per-component control is available through the liveUpdates prop.
-
- Default (inherits global setting)
-
-
-
-
- Always On (liveUpdates=true)
-
-
-
-
- Locked (liveUpdates=false)
-
-
+
+
+
diff --git a/implementations/web-sdk_react/src/pages/PageTwoPage.tsx b/implementations/web-sdk_react/src/pages/PageTwoPage.tsx
index 596e86c86..f6921ce87 100644
--- a/implementations/web-sdk_react/src/pages/PageTwoPage.tsx
+++ b/implementations/web-sdk_react/src/pages/PageTwoPage.tsx
@@ -27,7 +27,7 @@ export function PageTwoPage({ consent, entriesById, isIdentified }: PageTwoPageP
const handleDemoCta = (): void => {
void trackView({
- componentId: 'page-two-demo-cta',
+ componentId: 'track-conversion-button',
viewId: crypto.randomUUID(),
viewDurationMs: 0,
})
@@ -35,6 +35,9 @@ export function PageTwoPage({ consent, entriesById, isIdentified }: PageTwoPageP
return (
+
+ Back to Home
+
Page Two
Demo route for SPA navigation, route context (/page-two), and conversion-style
@@ -68,14 +71,10 @@ export function PageTwoPage({ consent, entriesById, isIdentified }: PageTwoPageP
Conversion Step Demo
-
+
Trigger Page Two CTA Event
-
-
- Back to Home
-
)
}
diff --git a/implementations/web-sdk_react/src/sections/LiveUpdatesExampleEntry.tsx b/implementations/web-sdk_react/src/sections/LiveUpdatesExampleEntry.tsx
index d613b72e3..6c69cdbb4 100644
--- a/implementations/web-sdk_react/src/sections/LiveUpdatesExampleEntry.tsx
+++ b/implementations/web-sdk_react/src/sections/LiveUpdatesExampleEntry.tsx
@@ -63,11 +63,10 @@ export function LiveUpdatesExampleEntry({
const fullLabel = `${text} [Entry: ${resolvedEntry.sys.id}]`
return (
-
+
{text}
-
Entry: {resolvedEntry.sys.id}
)
}
diff --git a/lib/e2e-web/README.md b/lib/e2e-web/README.md
new file mode 100644
index 000000000..ac82d09df
--- /dev/null
+++ b/lib/e2e-web/README.md
@@ -0,0 +1,83 @@
+# e2e-web
+
+Shared Playwright E2E suite for CSR web SDK implementations. The suite owns the test specs as a
+single source of truth β each implementation runs them against its own dev server via the
+`IMPLEMENTATION` env var, so tracking and consent behaviour is verified consistently across React
+and Angular without duplicating spec files.
+
+## Running tests
+
+```sh
+# First-time setup β installs Playwright browsers, run once from the repo root
+pnpm --dir lib/e2e-web setup:e2e
+
+# Run the full suite (from an implementation directory)
+pnpm test:e2e
+
+# Open the interactive UI runner
+pnpm test:e2e:ui
+
+# View the last HTML report
+pnpm test:e2e:report
+```
+
+Supported implementations: `web-sdk_react`, `web-sdk_angular`.
+
+## How it works
+
+`playwright.config.mjs` uses two env vars to know which app to test:
+
+- `IMPLEMENTATION` β the folder name under `implementations/` (e.g. `web-sdk_angular`). The config
+ uses this to resolve the implementation directory and registers its `serve:e2e` script as a
+ Playwright `webServer`. Playwright starts the server automatically before the suite runs and shuts
+ it down after β or reuses it if it is already listening on the port. This means no manual pm2
+ process tracking: you can run `pnpm test:e2e` from a cold start and the app comes up, tests run,
+ and the process is cleaned up without any extra steps. Playwright browsers are installed once in
+ `lib/e2e-web` via `setup:e2e` and shared across all implementations β no per-implementation
+ install, no duplicating Playwright config, test scripts, or spec files. The value is validated
+ against `/^[a-z0-9_-]+$/` and resolved to an absolute path before use, so it is never interpolated
+ directly into a shell command.
+- `APP_PORT` β the port the app listens on. Defaults to `3000`. Angular dev server uses `4200`, so
+ it must be set explicitly. Having a configurable port also makes it possible to run E2E suites for
+ multiple implementations in parallel β each on its own port with no conflicts.
+
+The config also loads the implementation's `.env` file via `process.loadEnvFile()` before the suite
+runs, so vars like `PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL` are available to both the test process
+and the app server without any manual forwarding.
+
+Each implementation sets both in its own `test:e2e` script:
+
+```sh
+# web-sdk_angular
+IMPLEMENTATION=web-sdk_angular APP_PORT=4200 pnpm --dir ../../lib/e2e-web test
+
+# web-sdk_react
+IMPLEMENTATION=web-sdk_react pnpm --dir ../../lib/e2e-web test
+```
+
+The config also starts the shared mock server (`lib/mocks`) with the same lifecycle β spun up before
+the suite and reused if already running. Each implementation registers its own `serve:e2e` command,
+so the server startup behaviour (port, build step, env) is fully owned by that implementation.
+
+## Adding a new implementation
+
+Two changes are needed in the implementation's `package.json` β no new dependencies, no new spec
+files, no Playwright config:
+
+```jsonc
+// implementations/my-web-sdk/package.json
+{
+ "scripts": {
+ // Builds and starts the app on a fixed port for Playwright to target
+ "serve:e2e": "
",
+
+ // Points the shared suite at this implementation; .env is loaded automatically
+ "test:e2e": "IMPLEMENTATION=my-web-sdk pnpm --dir ../../lib/e2e-web test",
+ },
+}
+```
+
+Then align UI labels and `data-testid` attributes with the selectors used in the specs.
+
+Playwright browsers are installed once in `lib/e2e-web` and shared β run `pnpm setup:e2e` there once
+before running any implementation's suite for the first time.
diff --git a/implementations/web-sdk_react/e2e/displays-identified-user-variants.spec.ts b/lib/e2e-web/e2e/displays-identified-user-variants.spec.ts
similarity index 86%
rename from implementations/web-sdk_react/e2e/displays-identified-user-variants.spec.ts
rename to lib/e2e-web/e2e/displays-identified-user-variants.spec.ts
index da15b1f6d..7b6085d44 100644
--- a/implementations/web-sdk_react/e2e/displays-identified-user-variants.spec.ts
+++ b/lib/e2e-web/e2e/displays-identified-user-variants.spec.ts
@@ -1,21 +1,23 @@
import { expect, test } from '@playwright/test'
test.describe('identified user', () => {
+ test.use({ storageState: { cookies: [], origins: [] } })
+
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('domcontentloaded')
await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible()
await page.getByTestId('consent-button').click()
- await expect(page.getByTestId('consent-status')).toHaveText('Consent: true')
+ await expect(page.getByTestId('consent-status')).toHaveText('Yes')
- await page.getByTestId('live-updates-identify-button').click()
- await expect(page.getByTestId('live-updates-reset-button')).toBeVisible()
+ await page.getByTestId('identify-button').click()
+ await expect(page.getByTestId('reset-button')).toBeVisible()
await page.reload()
await page.waitForLoadState('domcontentloaded')
await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible()
- await expect(page.getByTestId('live-updates-reset-button')).toBeVisible()
+ await expect(page.getByTestId('reset-button')).toBeVisible()
})
test('displays common variants', async ({ page }) => {
@@ -70,14 +72,14 @@ test.describe('identified user', () => {
})
test('reset persists unidentified state across reload', async ({ page }) => {
- await page.getByTestId('live-updates-reset-button').click()
- await expect(page.getByTestId('live-updates-identify-button')).toBeVisible()
+ await page.getByTestId('reset-button').click()
+ await expect(page.getByTestId('identify-button')).toBeVisible()
await page.reload()
await page.waitForLoadState('domcontentloaded')
await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible()
- await expect(page.getByTestId('live-updates-identify-button')).toBeVisible()
+ await expect(page.getByTestId('identify-button')).toBeVisible()
await expect(
page.getByText('This is a baseline content entry for all identified or unidentified users.'),
).toBeVisible()
diff --git a/implementations/web-sdk_react/e2e/displays-unidentified-user-variants.spec.ts b/lib/e2e-web/e2e/displays-unidentified-user-variants.spec.ts
similarity index 97%
rename from implementations/web-sdk_react/e2e/displays-unidentified-user-variants.spec.ts
rename to lib/e2e-web/e2e/displays-unidentified-user-variants.spec.ts
index 853909379..bef5fb73b 100644
--- a/implementations/web-sdk_react/e2e/displays-unidentified-user-variants.spec.ts
+++ b/lib/e2e-web/e2e/displays-unidentified-user-variants.spec.ts
@@ -1,6 +1,8 @@
import { expect, test } from '@playwright/test'
test.describe('unidentified user', () => {
+ test.use({ storageState: { cookies: [], origins: [] } })
+
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('domcontentloaded')
diff --git a/implementations/web-sdk_react/e2e/entry-click-tracking.spec.ts b/lib/e2e-web/e2e/entry-click-tracking.spec.ts
similarity index 100%
rename from implementations/web-sdk_react/e2e/entry-click-tracking.spec.ts
rename to lib/e2e-web/e2e/entry-click-tracking.spec.ts
diff --git a/implementations/web-sdk_react/e2e/entry-hover-tracking.spec.ts b/lib/e2e-web/e2e/entry-hover-tracking.spec.ts
similarity index 64%
rename from implementations/web-sdk_react/e2e/entry-hover-tracking.spec.ts
rename to lib/e2e-web/e2e/entry-hover-tracking.spec.ts
index 83500179c..7daa2951f 100644
--- a/implementations/web-sdk_react/e2e/entry-hover-tracking.spec.ts
+++ b/lib/e2e-web/e2e/entry-hover-tracking.spec.ts
@@ -18,35 +18,19 @@ const hoverScenarios: HoverScenario[] = [
},
]
-function parseHoverDurationMs(label: string): number {
- const match = /Hover Duration:\s*(\d+)ms/.exec(label)
- if (!match?.[1]) return Number.NaN
-
- return Number.parseInt(match[1], 10)
-}
-
-function parseHoverId(testId: string | null): string | undefined {
- if (!testId) return undefined
-
- const prefix = 'event-component_hover-hover-'
- return testId.startsWith(prefix) ? testId.slice(prefix.length) : undefined
-}
-
async function movePointerAwayFromEntries(page: Page): Promise {
await page.getByRole('heading', { name: 'Utilities' }).hover()
}
-async function readResolvedEntryId(page: Page): Promise {
- const entryId = await page
- .getByTestId(`content-${HOVER_ENTRY_BASELINE_ID}`)
- .getAttribute('data-ctfl-entry-id')
-
- return entryId ?? ''
+async function readHoverEventId(page: Page): Promise {
+ return (await page.locator('[data-hover-id]').first().getAttribute('data-hover-id')) ?? ''
}
async function readHoverDurationMs(page: Page, hoverId: string): Promise {
- const label = await page.getByTestId(`event-component_hover-hover-${hoverId}`).innerText()
- return parseHoverDurationMs(label)
+ const value = await page
+ .locator(`[data-hover-id="${hoverId}"]`)
+ .getAttribute('data-hover-duration-ms')
+ return value !== null ? Number.parseInt(value, 10) : Number.NaN
}
test.describe('entry hover tracking', () => {
@@ -67,7 +51,7 @@ test.describe('entry hover tracking', () => {
await movePointerAwayFromEntries(page)
}
- await expect(page.locator('[data-testid^="event-component_hover-hover-"]')).toHaveCount(0)
+ await expect(page.locator('[data-hover-id]')).toHaveCount(0)
})
test('emits entry hover events for entry container and descendant hovers after consent', async ({
@@ -75,14 +59,7 @@ test.describe('entry hover tracking', () => {
}) => {
await page.getByTestId('consent-button').click()
- await expect
- .poll(async () => await readResolvedEntryId(page), {
- message: 'resolved entry id should be available',
- })
- .not.toEqual('')
- const resolvedEntryId = await readResolvedEntryId(page)
-
- const hoverEvents = page.locator('[data-testid^="event-component_hover-hover-"]')
+ const hoverEvents = page.locator('[data-hover-id]')
for (const scenario of hoverScenarios) {
const target = page.getByTestId(scenario.hoverTargetTestId)
@@ -97,7 +74,6 @@ test.describe('entry hover tracking', () => {
})
.toBeGreaterThan(baselineCount)
- await expect(hoverEvents.first()).toContainText(`Entry/Flag: ${resolvedEntryId}`)
await movePointerAwayFromEntries(page)
}
@@ -109,23 +85,14 @@ test.describe('entry hover tracking', () => {
}) => {
await page.getByTestId('consent-button').click()
- await expect
- .poll(async () => await readResolvedEntryId(page), {
- message: 'resolved entry id should be available',
- })
- .not.toEqual('')
- const resolvedEntryId = await readResolvedEntryId(page)
-
const target = page.getByTestId(`content-${HOVER_ENTRY_BASELINE_ID}`)
await target.scrollIntoViewIfNeeded()
await target.hover()
- const hoverEvent = page.locator('[data-testid^="event-component_hover-hover-"]').first()
- await expect(hoverEvent).toBeVisible()
- await expect(hoverEvent).toContainText(`Entry/Flag: ${resolvedEntryId}`)
+ const hoverEvents = page.locator('[data-hover-id]')
+ await expect(hoverEvents.first()).toBeVisible()
- const hoverEventTestId = await hoverEvent.getAttribute('data-testid')
- const hoverId = parseHoverId(hoverEventTestId)
+ const hoverId = await readHoverEventId(page)
expect(hoverId).toBeTruthy()
if (!hoverId) return
diff --git a/implementations/web-sdk_react/e2e/events-consent-gating.spec.ts b/lib/e2e-web/e2e/events-consent-gating.spec.ts
similarity index 95%
rename from implementations/web-sdk_react/e2e/events-consent-gating.spec.ts
rename to lib/e2e-web/e2e/events-consent-gating.spec.ts
index 80fb3e0c3..d2857b107 100644
--- a/implementations/web-sdk_react/e2e/events-consent-gating.spec.ts
+++ b/lib/e2e-web/e2e/events-consent-gating.spec.ts
@@ -32,7 +32,7 @@ test.describe('consent gating', () => {
await expect(pageEvents.first()).toBeVisible()
- await page.getByRole('button', { name: 'Accept Consent' }).click()
+ await page.getByTestId('consent-button').click()
await scrollThroughEntries(page)
await expect.poll(async () => await viewEvents.count()).toBeGreaterThan(0)
diff --git a/lib/e2e-web/e2e/flag-view-tracking.spec.ts b/lib/e2e-web/e2e/flag-view-tracking.spec.ts
new file mode 100644
index 000000000..2f5043045
--- /dev/null
+++ b/lib/e2e-web/e2e/flag-view-tracking.spec.ts
@@ -0,0 +1,72 @@
+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 view events without consent', async ({ page }) => {
+ const flagEvents = page.locator('[data-testid="event-component-boolean"]')
+
+ // no events on page load
+ await expect(flagEvents).toHaveCount(0)
+
+ // identifying without consent must not record flag exposure
+ await page.getByTestId('identify-button').click()
+ await expect(page.getByTestId('reset-button')).toBeVisible()
+
+ await expect(flagEvents).toHaveCount(0)
+ })
+
+ test('emits flag view event on consent grant for identified user β anonymous exposure must be recorded for complete experiment analysis', async ({
+ page,
+ }) => {
+ const flagEvents = page.locator('[data-testid="event-component-boolean"]')
+
+ // identify first β flag value updates but no event because no consent yet
+ await page.getByTestId('identify-button').click()
+ await expect(page.getByTestId('reset-button')).toBeVisible()
+ await expect(flagEvents).toHaveCount(0)
+
+ // consent opens a fresh subscription which emits the current flag value immediately,
+ // recording exposure even though identity was already established before consent
+ await page.getByTestId('consent-button').click()
+
+ await expect
+ .poll(async () => await flagEvents.count(), {
+ message: 'granting consent after identify should still emit a flag view event',
+ })
+ .toBeGreaterThan(0)
+ })
+
+ test('emits flag view event on consent for anonymous user, then again after identify β each profile change re-records exposure', async ({
+ page,
+ }) => {
+ const flagEvents = page.locator('[data-testid="event-component-boolean"]')
+ const baselineFlagEventCount = await flagEvents.count()
+
+ // consent while anonymous β records the anonymous user's exposure
+ await page.getByTestId('consent-button').click()
+
+ await expect
+ .poll(async () => await flagEvents.count(), {
+ message: 'consented flag subscription should emit a flag view event',
+ })
+ .toBeGreaterThan(baselineFlagEventCount)
+
+ const afterConsentFlagEventCount = await flagEvents.count()
+
+ // identify β profile change may update the flag value, re-recording exposure for the
+ // now-identified user
+ await page.getByTestId('identify-button').click()
+ await expect(page.getByTestId('reset-button')).toBeVisible()
+
+ await expect
+ .poll(async () => await flagEvents.count(), {
+ message: 'profile updates should emit additional flag view events',
+ })
+ .toBeGreaterThan(afterConsentFlagEventCount)
+ })
+})
diff --git a/implementations/web-sdk_react/e2e/live-updates.spec.ts b/lib/e2e-web/e2e/live-updates.spec.ts
similarity index 76%
rename from implementations/web-sdk_react/e2e/live-updates.spec.ts
rename to lib/e2e-web/e2e/live-updates.spec.ts
index be4a212e2..80c07c3be 100644
--- a/implementations/web-sdk_react/e2e/live-updates.spec.ts
+++ b/lib/e2e-web/e2e/live-updates.spec.ts
@@ -3,12 +3,11 @@ import { type Locator, type Page, expect, test } from '@playwright/test'
const isPreviewPanelEnabled = process.env.PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL === 'true'
async function getEntryId(locator: Locator): Promise {
- const text = await locator.innerText()
- return text.replace('Entry: ', '').trim()
+ return (await locator.getAttribute('data-test-entry-id')) ?? ''
}
async function identify(page: Page): Promise {
- await page.getByTestId('live-updates-identify-button').click()
+ await page.getByTestId('identify-button').click()
await expect(page.getByTestId('identified-status')).toHaveText('Yes')
}
@@ -32,7 +31,7 @@ test.describe('live updates behavior', () => {
await expect
.poll(async () => {
const text = await page.getByTestId('selected-optimizations-count').innerText()
- const value = Number.parseInt(text.replace('Selected Optimizations: ', ''), 10)
+ const value = Number.parseInt(text, 10)
return Number.isNaN(value) ? 0 : value
})
.toBeGreaterThan(0)
@@ -41,12 +40,13 @@ test.describe('live updates behavior', () => {
test('default behavior locks to first value when global live updates is OFF', async ({
page,
}) => {
- const initialDefaultEntryId = await getEntryId(page.getByTestId('entry-id-live-default'))
+ const initialDefaultEntryId = await getEntryId(page.getByTestId('content-live-default'))
await identify(page)
- await expect(page.getByTestId('entry-id-live-default')).toHaveText(
- `Entry: ${initialDefaultEntryId}`,
+ await expect(page.getByTestId('content-live-default')).toHaveAttribute(
+ 'data-test-entry-id',
+ initialDefaultEntryId,
)
})
@@ -56,34 +56,35 @@ test.describe('live updates behavior', () => {
await page.getByTestId('toggle-global-live-updates-button').click()
await expect(page.getByTestId('global-live-updates-status')).toHaveText('ON')
- const initialDefaultEntryId = await getEntryId(page.getByTestId('entry-id-live-default'))
- const initialLockedEntryId = await getEntryId(page.getByTestId('entry-id-live-locked'))
+ const initialDefaultEntryId = await getEntryId(page.getByTestId('content-live-default'))
+ const initialLockedEntryId = await getEntryId(page.getByTestId('content-live-locked'))
await identify(page)
await expect
- .poll(async () => await getEntryId(page.getByTestId('entry-id-live-default')))
+ .poll(async () => await getEntryId(page.getByTestId('content-live-default')))
.not.toBe(initialDefaultEntryId)
- await expect(page.getByTestId('entry-id-live-locked')).toHaveText(
- `Entry: ${initialLockedEntryId}`,
+ await expect(page.getByTestId('content-live-locked')).toHaveAttribute(
+ 'data-test-entry-id',
+ initialLockedEntryId,
)
})
test('per-component liveUpdates=true updates even when global live updates is OFF', async ({
page,
}) => {
- const initialLiveEntryId = await getEntryId(page.getByTestId('entry-id-live-enabled'))
+ const initialLiveEntryId = await getEntryId(page.getByTestId('content-live-enabled'))
await identify(page)
await expect
- .poll(async () => await getEntryId(page.getByTestId('entry-id-live-enabled')))
+ .poll(async () => await getEntryId(page.getByTestId('content-live-enabled')))
.not.toBe(initialLiveEntryId)
})
test('preview-panel override enables updates for locked entries', async ({ page }) => {
test.skip(!isPreviewPanelEnabled, 'Preview panel is disabled for this build.')
- const initialLockedEntryId = await getEntryId(page.getByTestId('entry-id-live-locked'))
+ const initialLockedEntryId = await getEntryId(page.getByTestId('content-live-locked'))
await page.getByTestId('simulate-preview-panel-button').click()
await expect(page.getByTestId('preview-panel-status')).toHaveText('Open')
@@ -91,7 +92,7 @@ test.describe('live updates behavior', () => {
await identify(page)
await expect
- .poll(async () => await getEntryId(page.getByTestId('entry-id-live-locked')))
+ .poll(async () => await getEntryId(page.getByTestId('content-live-locked')))
.not.toBe(initialLockedEntryId)
})
@@ -112,17 +113,13 @@ test.describe('live updates behavior', () => {
test('screen controls identify and reset user', async ({ page }) => {
await expect(page.getByTestId('identified-status')).toHaveText('No')
- await page.getByTestId('live-updates-identify-button').click()
+ await page.getByTestId('identify-button').click()
await expect(page.getByTestId('identified-status')).toHaveText('Yes')
- await page.getByTestId('live-updates-reset-button').click()
+ await page.getByTestId('reset-button').click()
await expect(page.getByTestId('identified-status')).toHaveText('No')
})
test('renders default, enabled, and locked examples', async ({ page }) => {
- await expect(page.getByTestId('live-updates-default')).toBeVisible()
- await expect(page.getByTestId('live-updates-enabled')).toBeVisible()
- await expect(page.getByTestId('live-updates-locked')).toBeVisible()
-
await expect(page.getByTestId('content-live-default')).toBeVisible()
await expect(page.getByTestId('content-live-enabled')).toBeVisible()
await expect(page.getByTestId('content-live-locked')).toBeVisible()
@@ -130,8 +127,5 @@ test.describe('live updates behavior', () => {
await expect(page.getByTestId('entry-text-live-default')).toBeVisible()
await expect(page.getByTestId('entry-text-live-enabled')).toBeVisible()
await expect(page.getByTestId('entry-text-live-locked')).toBeVisible()
- await expect(page.getByTestId('entry-id-live-default')).toBeVisible()
- await expect(page.getByTestId('entry-id-live-enabled')).toBeVisible()
- await expect(page.getByTestId('entry-id-live-locked')).toBeVisible()
})
})
diff --git a/implementations/web-sdk_react/e2e/navigation-page-events.spec.ts b/lib/e2e-web/e2e/navigation-page-events.spec.ts
similarity index 88%
rename from implementations/web-sdk_react/e2e/navigation-page-events.spec.ts
rename to lib/e2e-web/e2e/navigation-page-events.spec.ts
index 9f9c36f64..66651d0d5 100644
--- a/implementations/web-sdk_react/e2e/navigation-page-events.spec.ts
+++ b/lib/e2e-web/e2e/navigation-page-events.spec.ts
@@ -6,14 +6,10 @@ async function getRecentPageEventUrls(page: Page): Promise {
const urls: string[] = []
for (let index = 0; index < count; index += 1) {
- const text = await pageEvents.nth(index).innerText()
- const marker = 'URL: '
- const markerIndex = text.indexOf(marker)
- if (markerIndex === -1) {
- continue
+ const url = await pageEvents.nth(index).getAttribute('data-page-url')
+ if (url !== null) {
+ urls.push(url)
}
-
- urls.push(text.slice(markerIndex + marker.length).trim())
}
return urls
diff --git a/implementations/web-sdk_react/e2e/offline-queue-recovery.spec.ts b/lib/e2e-web/e2e/offline-queue-recovery.spec.ts
similarity index 80%
rename from implementations/web-sdk_react/e2e/offline-queue-recovery.spec.ts
rename to lib/e2e-web/e2e/offline-queue-recovery.spec.ts
index 9634bebe3..75a0ddd43 100644
--- a/implementations/web-sdk_react/e2e/offline-queue-recovery.spec.ts
+++ b/lib/e2e-web/e2e/offline-queue-recovery.spec.ts
@@ -1,13 +1,8 @@
import { type BrowserContext, type Page, expect, test } from '@playwright/test'
-function parseCounterValue(text: string): number {
- const match = /:\s*(\d+)/.exec(text)
- return match?.[1] ? Number.parseInt(match[1], 10) : 0
-}
-
async function getRawEventsCount(page: Page): Promise {
const text = await page.getByTestId('raw-events-count').innerText()
- return parseCounterValue(text)
+ return Number.parseInt(text, 10)
}
async function expectRawEventsToIncrease(page: Page, baselineCount: number): Promise {
@@ -20,7 +15,6 @@ async function setOffline(context: BrowserContext, offline: boolean): Promise {
await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible()
- await expect(page.getByTestId('events-count')).toBeVisible()
await expect(page.getByTestId('raw-events-count')).toBeVisible()
}
@@ -43,7 +37,7 @@ test.describe('offline queue and recovery', () => {
await setOffline(context, true)
await page.getByTestId('link-page-two').click()
await expect(page.getByTestId('page-two-view')).toBeVisible()
- await page.getByTestId('page-two-demo-cta').click()
+ await page.getByTestId('track-conversion-button').click()
await expectRawEventsToIncrease(page, baselineCount)
})
@@ -55,7 +49,7 @@ test.describe('offline queue and recovery', () => {
await setOffline(context, false)
await page.getByTestId('link-back-home').click()
await waitForBaseUi(page)
- await expect(page.getByTestId('live-updates-identify-button')).toBeVisible()
+ await expect(page.getByTestId('identify-button')).toBeVisible()
})
test('remains stable across rapid network state changes', async ({ context, page }) => {
@@ -65,11 +59,11 @@ test.describe('offline queue and recovery', () => {
await setOffline(context, false)
await waitForBaseUi(page)
- await expect(page.getByTestId('live-updates-identify-button')).toBeVisible()
+ await expect(page.getByTestId('identify-button')).toBeVisible()
const baselineCount = await getRawEventsCount(page)
await page.getByTestId('link-page-two').click()
await expect(page.getByTestId('page-two-view')).toBeVisible()
- await page.getByTestId('page-two-demo-cta').click()
+ await page.getByTestId('track-conversion-button').click()
await expectRawEventsToIncrease(page, baselineCount)
})
@@ -80,12 +74,12 @@ test.describe('offline queue and recovery', () => {
const baselineCount = await getRawEventsCount(page)
await setOffline(context, true)
- await page.getByTestId('live-updates-identify-button').click()
+ await page.getByTestId('identify-button').click()
await expectRawEventsToIncrease(page, baselineCount)
await expect(page.getByTestId('identified-status')).toHaveText('No')
await setOffline(context, false)
- await expect(page.getByTestId('live-updates-reset-button')).toBeVisible()
+ await expect(page.getByTestId('reset-button')).toBeVisible()
await expect(page.getByTestId('identified-status')).toHaveText('Yes')
})
})
diff --git a/lib/e2e-web/package.json b/lib/e2e-web/package.json
new file mode 100644
index 000000000..b6ddfe406
--- /dev/null
+++ b/lib/e2e-web/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "e2e-web",
+ "private": true,
+ "version": "0.0.0",
+ "description": "Shared Playwright E2E suite for CSR web SDK implementations.",
+ "license": "MIT",
+ "scripts": {
+ "test": "playwright test",
+ "test:ui": "playwright test --ui",
+ "test:report": "playwright show-report",
+ "playwright:install": "playwright install",
+ "playwright:install-deps": "playwright install-deps",
+ "setup:e2e": "pnpm playwright:install && pnpm playwright:install-deps",
+ "test:unit": "echo \"No unit tests necessary\""
+ },
+ "devDependencies": {
+ "@playwright/test": "catalog:",
+ "@types/node": "catalog:"
+ }
+}
diff --git a/lib/e2e-web/playwright.config.mjs b/lib/e2e-web/playwright.config.mjs
new file mode 100644
index 000000000..f0f6105b0
--- /dev/null
+++ b/lib/e2e-web/playwright.config.mjs
@@ -0,0 +1,66 @@
+import { defineConfig, devices } from '@playwright/test'
+import { resolve } from 'path'
+
+const isCI = Boolean(process.env.CI)
+
+const PORT = process.env.APP_PORT ?? 3000
+const BASE_URL = `http://localhost:${PORT}`
+
+const IMPLEMENTATION = process.env.IMPLEMENTATION
+if (IMPLEMENTATION && !/^[a-z0-9_-]+$/.test(IMPLEMENTATION)) {
+ throw new Error(`Invalid IMPLEMENTATION: ${IMPLEMENTATION}`)
+}
+const IMPL_DIR = IMPLEMENTATION
+ ? resolve(import.meta.dirname, '../../implementations', IMPLEMENTATION)
+ : null
+
+if (IMPL_DIR) {
+ try {
+ process.loadEnvFile(resolve(IMPL_DIR, '.env'))
+ } catch {
+ // .env not present is fine
+ }
+}
+
+export default defineConfig({
+ testDir: './e2e',
+ fullyParallel: true,
+ forbidOnly: isCI,
+ retries: isCI ? 2 : 0,
+ workers: 1,
+ timeout: 60000,
+ expect: { timeout: 10_000 },
+ reporter: [['html', { open: 'never' }]],
+ use: {
+ baseURL: BASE_URL,
+ // 'on-first-retry' never writes traces locally (0 retries) β the UI trace viewer
+ // hits 404/500 on every snapshot load. Always capture locally so the viewer works.
+ trace: isCI ? 'on-first-retry' : 'on',
+ video: 'on-first-retry',
+ },
+ projects: [
+ { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
+ { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
+ { name: 'webkit', use: { ...devices['Desktop Safari'] } },
+ ],
+ webServer: [
+ {
+ name: 'Mocks',
+ command: 'pnpm --dir ../mocks serve',
+ url: 'http://localhost:8000/health',
+ reuseExistingServer: true,
+ timeout: 120_000,
+ stdout: 'pipe',
+ stderr: 'pipe',
+ },
+ {
+ name: 'App',
+ command: IMPL_DIR ? `pnpm --dir ${IMPL_DIR} serve:e2e` : 'tail -f /dev/null',
+ url: BASE_URL,
+ reuseExistingServer: true,
+ timeout: 60_000,
+ stdout: 'pipe',
+ stderr: 'pipe',
+ },
+ ],
+})
diff --git a/lib/e2e-web/tsconfig.json b/lib/e2e-web/tsconfig.json
new file mode 100644
index 000000000..7dbee1af3
--- /dev/null
+++ b/lib/e2e-web/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "dom"],
+ "module": "ES2022",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "forceConsistentCasingInFileNames": true,
+ "noFallthroughCasesInSwitch": true,
+ "skipLibCheck": true,
+ "types": ["node"]
+ },
+ "include": ["e2e/**/*.ts"]
+}
diff --git a/lib/mocks/src/server.ts b/lib/mocks/src/server.ts
index 2cbad4607..f4002bc0c 100644
--- a/lib/mocks/src/server.ts
+++ b/lib/mocks/src/server.ts
@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-magic-numbers -- testing */
import { createServer } from '@mswjs/http-middleware'
+import { HttpResponse, http } from 'msw'
import { getHandlers as getContentfulHandlers } from './contentful-handlers'
import { getHandlers as getExperienceHandlers } from './experience-handlers'
import { getHandlers as getInsightsHandlers } from './insights-handlers'
@@ -19,6 +20,7 @@ const EXPERIENCE_BASE_URL =
const INSIGHTS_BASE_URL = process.env.INSIGHTS_BASE_URL ?? `${BASE_HOST}:${PORT}${INSIGHTS_PATH}`
const app = createServer(
+ http.get('*/health', () => HttpResponse.text('ok')),
...getContentfulHandlers(`*${CONTENTFUL_PATH}`),
...getExperienceHandlers(`*${EXPERIENCE_PATH}`),
...getInsightsHandlers(`*${INSIGHTS_PATH}`),
diff --git a/package.json b/package.json
index f5b075a25..e4fe79bd7 100644
--- a/package.json
+++ b/package.json
@@ -38,8 +38,8 @@
"notices:generate:npm": "bash ./scripts/generate-third-party-notices.sh npm",
"notices:generate:swift": "bash ./scripts/generate-third-party-notices.sh swift",
"pack:pkgs": "bash ./scripts/pack-pkgs.sh",
- "playwright:install": "pnpm run implementation:run -- --all -- implementation:playwright:install",
- "playwright:install-deps": "pnpm run implementation:run -- --all -- implementation:playwright:install-deps",
+ "playwright:install": "pnpm run implementation:run -- --all -- implementation:playwright:install && pnpm --dir lib/e2e-web run playwright:install",
+ "playwright:install-deps": "pnpm run implementation:run -- --all -- implementation:playwright:install-deps && pnpm --dir lib/e2e-web run playwright:install-deps",
"pm2:delete:all": "pm2 delete all",
"pm2:list": "pm2 list",
"pm2:logs": "pm2 logs",
@@ -52,7 +52,8 @@
"setup:e2e:node-sdk+web-sdk": "pnpm run build:pkgs && pnpm run implementation:run -- node-sdk+web-sdk implementation:install && pnpm run implementation:run -- node-sdk+web-sdk implementation:setup:e2e",
"setup:e2e:react-native-sdk": "pnpm run build:pkgs && pnpm run implementation:run -- react-native-sdk implementation:install && pnpm run implementation:run -- react-native-sdk implementation:setup:e2e",
"setup:e2e:react-web-sdk": "pnpm run build:pkgs && pnpm run implementation:run -- react-web-sdk implementation:install && pnpm run implementation:run -- react-web-sdk implementation:setup:e2e",
- "setup:e2e:web-sdk_react": "pnpm run build:pkgs && pnpm run implementation:run -- web-sdk_react implementation:install && pnpm run implementation:run -- web-sdk_react implementation:setup:e2e",
+ "setup:e2e:web-sdk_angular": "pnpm run build:pkgs && pnpm run implementation:run -- web-sdk_angular implementation:install && pnpm --dir lib/e2e-web setup:e2e",
+ "setup:e2e:web-sdk_react": "pnpm run build:pkgs && pnpm run implementation:run -- web-sdk_react implementation:install && pnpm --dir lib/e2e-web setup:e2e",
"setup:e2e:web-sdk": "pnpm run build:pkgs && pnpm run implementation:run -- web-sdk implementation:install && pnpm run implementation:run -- web-sdk implementation:setup:e2e",
"test:e2e": "pnpm run setup:e2e && pnpm run implementation:run -- --all -- implementation:test:e2e:run",
"test:e2e:ios-sdk": "pnpm run setup:e2e:ios-sdk && pnpm run implementation:ios-sdk -- test:e2e:ios:build:release && IOS_SCHEME=SwiftUI pnpm run implementation:ios-sdk -- test:e2e:ios:run:release && IOS_SCHEME=UIKit pnpm run implementation:ios-sdk -- test:e2e:ios:run:release",
@@ -60,6 +61,7 @@
"test:e2e:node-sdk+web-sdk": "pnpm run setup:e2e:node-sdk+web-sdk && pnpm run implementation:run -- node-sdk+web-sdk implementation:test:e2e:run",
"test:e2e:react-native-sdk": "pnpm run setup:e2e:react-native-sdk && pnpm run implementation:run -- react-native-sdk implementation:test:e2e:run",
"test:e2e:react-web-sdk": "pnpm run setup:e2e:react-web-sdk && pnpm run implementation:run -- react-web-sdk implementation:test:e2e:run",
+ "test:e2e:web-sdk_angular": "pnpm run setup:e2e:web-sdk_angular && pnpm run implementation:run -- web-sdk_angular implementation:test:e2e:run",
"test:e2e:web-sdk_react": "pnpm run setup:e2e:web-sdk_react && pnpm run implementation:run -- web-sdk_react implementation:test:e2e:run",
"test:e2e:web-sdk": "pnpm run setup:e2e:web-sdk && pnpm run implementation:run -- web-sdk implementation:test:e2e:run",
"size:check": "pnpm --filter @contentful/* --stream size:check",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7d793e66e..45e15e95f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -83,7 +83,7 @@ catalogs:
version: 4.3.6
overrides:
- '@playwright/test': ^1.57.0
+ '@playwright/test': 1.61.0
'@types/node': ^24.0.13
typescript: ^5.8.3
vitest: ^3.2.4
@@ -180,6 +180,15 @@ importers:
specifier: ^5.8.3
version: 5.9.3
+ lib/e2e-web:
+ devDependencies:
+ '@playwright/test':
+ specifier: 1.61.0
+ version: 1.61.0
+ '@types/node':
+ specifier: ^24.0.13
+ version: 24.10.13
+
lib/mocks:
dependencies:
'@contentful/optimization-api-schemas':
@@ -663,7 +672,7 @@ importers:
version: 20.8.9
next:
specifier: ^16.2.6
- version: 16.2.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ version: 16.2.6(@playwright/test@1.61.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react-router-dom:
specifier: ^7.14.2
version: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -2117,6 +2126,11 @@ packages:
resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
+ '@playwright/test@1.61.0':
+ resolution: {integrity: sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
'@pm2/agent@2.1.1':
resolution: {integrity: sha512-0V9ckHWd/HSC8BgAbZSoq8KXUG81X97nSkAxmhKDhmF8vanyaoc1YXwc2KVkbWz82Rg4gjd2n9qiT3i7bdvGrQ==}
@@ -4598,6 +4612,11 @@ packages:
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
+ fsevents@2.3.2:
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -6053,7 +6072,7 @@ packages:
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.1.0
- '@playwright/test': ^1.57.0
+ '@playwright/test': 1.61.0
babel-plugin-react-compiler: '*'
react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
@@ -6407,6 +6426,16 @@ packages:
resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==}
engines: {node: '>=8'}
+ playwright-core@1.61.0:
+ resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ playwright@1.61.0:
+ resolution: {integrity: sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==}
+ engines: {node: '>=18'}
+ hasBin: true
+
pm2-axon-rpc@0.7.1:
resolution: {integrity: sha512-FbLvW60w+vEyvMjP/xom2UPhUN/2bVpdtLfKJeYM3gwzYhoTEEChCOICfFzxkxuoEleOlnpjie+n1nue91bDQw==}
engines: {node: '>=5'}
@@ -9314,6 +9343,10 @@ snapshots:
'@pkgr/core@0.2.9': {}
+ '@playwright/test@1.61.0':
+ dependencies:
+ playwright: 1.61.0
+
'@pm2/agent@2.1.1':
dependencies:
async: 3.2.6
@@ -12703,6 +12736,9 @@ snapshots:
fs.realpath@1.0.0: {}
+ fsevents@2.3.2:
+ optional: true
+
fsevents@2.3.3:
optional: true
@@ -14656,7 +14692,7 @@ snapshots:
netmask@2.0.2: {}
- next@16.2.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
+ next@16.2.6(@playwright/test@1.61.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
'@next/env': 16.2.6
'@swc/helpers': 0.5.15
@@ -14675,6 +14711,7 @@ snapshots:
'@next/swc-linux-x64-musl': 16.2.6
'@next/swc-win32-arm64-msvc': 16.2.6
'@next/swc-win32-x64-msvc': 16.2.6
+ '@playwright/test': 1.61.0
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
@@ -15003,6 +15040,14 @@ snapshots:
dependencies:
find-up: 4.1.0
+ playwright-core@1.61.0: {}
+
+ playwright@1.61.0:
+ dependencies:
+ playwright-core: 1.61.0
+ optionalDependencies:
+ fsevents: 2.3.2
+
pm2-axon-rpc@0.7.1:
dependencies:
debug: 4.4.3
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index f355365f1..eb31478be 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -20,7 +20,7 @@ catalog:
'@contentful/rich-text-html-renderer': ^17.1.6
'@contentful/rich-text-types': ^17.2.5
'@microsoft/api-extractor': ^7.57.7
- '@playwright/test': ^1.57.0
+ '@playwright/test': 1.61.0
'@preact/signals-core': ^1.13.0
'@rsbuild/core': ^1.7.3
'@rstest/core': ^0.8.5