From a92b176001bd1d1910f256b14f545c329156d12f Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Thu, 18 Jun 2026 15:13:25 +0200 Subject: [PATCH 01/24] feat(rendering): handle rendering concerns framework-agnostic; fix view tracking for OptimizedEntry wrappers - Extract OptimizedEntryController for framework-agnostic presentation logic - Add web-components entry point (ContentfulOptimizationRootElement, ContentfulOptimizedEntryElement) - Fix view tracking to support OptimizedEntry wrapper elements - Add ElementExistenceObserver and displayContentsViewSource for robust lifecycle handling - Update react-web-sdk hooks and OptimizedEntry to consume new controller API Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/main-pipeline.yaml | 62 +- AGENTS.md | 14 + ...rating-the-react-web-sdk-in-a-react-app.md | 20 +- implementations/web-sdk/README.md | 5 +- .../displays-identified-user-variants.spec.ts | 34 +- ...isplays-unidentified-user-variants.spec.ts | 42 +- .../web-sdk/e2e/entry-click-tracking.spec.ts | 7 +- .../web-sdk/e2e/entry-hover-tracking.spec.ts | 9 +- .../web-sdk/e2e/entry-view-tracking.spec.ts | 38 +- .../web-sdk/e2e/events-consent-gating.spec.ts | 41 ++ .../web-sdk/e2e/flag-view-tracking.spec.ts | 29 +- .../e2e/live-updates.spec.ts | 32 +- .../e2e/offline-queue-recovery.spec.ts | 88 +++ .../web-sdk/e2e/preview-panel.spec.ts | 6 +- .../e2e/web-components-lifecycle.spec.ts | 499 ++++++++++++++++ implementations/web-sdk/public/index.html | 553 ++++++++++++------ implementations/web-sdk/tsconfig.json | 1 + implementations/web-sdk_angular/angular.json | 13 +- implementations/web-sdk_angular/package.json | 9 + .../web-sdk_angular/src/app/app.html | 2 +- .../web-sdk_angular/src/app/app.ts | 5 + .../app/components/control-panel/index.html | 34 +- .../src/app/components/control-panel/index.ts | 11 +- .../src/app/components/entry-card/index.html | 7 +- .../src/app/components/entry-card/index.ts | 4 + .../app/components/tracking-log/index.html | 31 +- .../app/components/tracking-log/index.scss | 47 +- .../src/app/components/tracking-log/index.ts | 70 ++- .../src/app/pages/home/index.html | 12 +- .../src/app/pages/page-two/index.html | 59 +- .../src/app/pages/page-two/index.ts | 3 +- .../web-sdk_angular/src/app/services/entry.ts | 5 +- .../src/app/services/live-updates.ts | 8 + .../src/app/services/optimization.ts | 13 +- .../web-sdk_angular/src/app/utils.ts | 24 +- .../web-sdk_angular/src/styles.css | 6 +- implementations/web-sdk_react/.env.example | 2 +- implementations/web-sdk_react/package.json | 15 +- .../web-sdk_react/playwright.config.mjs | 83 --- .../web-sdk_react/rsbuild.config.ts | 2 +- implementations/web-sdk_react/src/App.tsx | 87 ++- .../src/components/AnalyticsEventDisplay.tsx | 210 ++++++- .../hooks/useOptimizationResolver.ts | 14 +- .../web-sdk_react/src/pages/HomePage.tsx | 110 ++-- .../web-sdk_react/src/pages/PageTwoPage.tsx | 11 +- .../src/sections/LiveUpdatesExampleEntry.tsx | 3 +- lib/build-tools/README.md | 4 + lib/build-tools/src/bundleSize.test.ts | 61 ++ lib/build-tools/src/bundleSize.ts | 113 +++- .../displays-identified-user-variants.spec.ts | 16 +- ...isplays-unidentified-user-variants.spec.ts | 2 + .../e2e-web}/e2e/entry-click-tracking.spec.ts | 0 .../e2e-web}/e2e/entry-hover-tracking.spec.ts | 55 +- .../e2e/events-consent-gating.spec.ts | 2 +- .../e2e-web}/e2e/flag-view-tracking.spec.ts | 8 +- lib/e2e-web/e2e/live-updates.spec.ts | 131 +++++ .../e2e/navigation-page-events.spec.ts | 10 +- .../e2e/offline-queue-recovery.spec.ts | 20 +- lib/e2e-web/package.json | 20 + lib/e2e-web/playwright.config.mjs | 58 ++ lib/e2e-web/tsconfig.json | 16 + lib/mocks/src/server.ts | 2 + package.json | 8 +- packages/AGENTS.md | 12 + packages/react-native-sdk/package.json | 2 +- packages/universal/api-client/package.json | 2 +- packages/universal/core-sdk/package.json | 2 +- packages/web/AGENTS.md | 7 + .../web/frameworks/react-web-sdk/AGENTS.md | 4 + .../web/frameworks/react-web-sdk/README.md | 80 ++- .../web/frameworks/react-web-sdk/package.json | 4 +- .../src/hooks/useOptimizationActions.ts | 48 ++ .../src/hooks/useOptimizationState.ts | 94 +++ .../react-web-sdk/src/index.test.tsx | 89 ++- .../web/frameworks/react-web-sdk/src/index.ts | 2 + .../optimized-entry/OptimizedEntry.test.tsx | 82 ++- .../src/optimized-entry/OptimizedEntry.tsx | 104 ++-- .../optimized-entry/optimizedEntryUtils.ts | 118 +--- .../src/optimized-entry/useOptimizedEntry.ts | 157 ++--- .../src/provider/OptimizationProvider.tsx | 94 +-- .../react-web-sdk/src/test/sdkTestUtils.tsx | 43 ++ packages/web/web-sdk/README.md | 72 +++ packages/web/web-sdk/dev/index.html | 470 +++++++++------ packages/web/web-sdk/dev/main.ts | 8 +- packages/web/web-sdk/package.json | 30 +- packages/web/web-sdk/rslib.config.ts | 27 + packages/web/web-sdk/src/constants.ts | 9 +- .../events/view/ElementViewObserver.ts | 133 ++--- .../view/createEntryViewDetector.test.ts | 251 ++++++++ .../view/displayContentsViewLifecycle.ts | 85 +++ .../events/view/displayContentsViewSource.ts | 230 ++++++++ .../element-view-observer-support.test.ts | 2 + .../view/element-view-observer-support.ts | 79 +++ .../view/elementViewSourceController.ts | 249 ++++++++ .../registry/ElementExistenceObserver.test.ts | 43 +- .../registry/ElementExistenceObserver.ts | 55 +- .../registry/EntryElementRegistry.test.ts | 49 ++ .../registry/EntryElementRegistry.ts | 11 +- .../OptimizedEntryController.test.ts | 451 ++++++++++++++ .../presentation/OptimizedEntryController.ts | 462 +++++++++++++++ .../web/web-sdk/src/presentation/index.ts | 23 + .../presentation/optimizationRootRuntime.ts | 108 ++++ .../ContentfulOptimizationRootElement.ts | 338 +++++++++++ .../ContentfulOptimizedEntryElement.ts | 392 +++++++++++++ .../web-sdk/src/web-components/index.test.ts | 496 ++++++++++++++++ .../web/web-sdk/src/web-components/index.ts | 57 ++ pnpm-lock.yaml | 108 ++-- pnpm-workspace.yaml | 2 +- 108 files changed, 6778 insertions(+), 1312 deletions(-) create mode 100644 implementations/web-sdk/e2e/events-consent-gating.spec.ts rename implementations/{web-sdk_react => web-sdk}/e2e/live-updates.spec.ts (81%) create mode 100644 implementations/web-sdk/e2e/offline-queue-recovery.spec.ts create mode 100644 implementations/web-sdk/e2e/web-components-lifecycle.spec.ts delete mode 100644 implementations/web-sdk_react/playwright.config.mjs rename {implementations/web-sdk_react => lib/e2e-web}/e2e/displays-identified-user-variants.spec.ts (86%) rename {implementations/web-sdk_react => lib/e2e-web}/e2e/displays-unidentified-user-variants.spec.ts (97%) rename {implementations/web-sdk_react => lib/e2e-web}/e2e/entry-click-tracking.spec.ts (100%) rename {implementations/web-sdk_react => lib/e2e-web}/e2e/entry-hover-tracking.spec.ts (64%) rename {implementations/web-sdk_react => lib/e2e-web}/e2e/events-consent-gating.spec.ts (95%) rename {implementations/web-sdk_react => lib/e2e-web}/e2e/flag-view-tracking.spec.ts (81%) create mode 100644 lib/e2e-web/e2e/live-updates.spec.ts rename {implementations/web-sdk_react => lib/e2e-web}/e2e/navigation-page-events.spec.ts (88%) rename {implementations/web-sdk_react => lib/e2e-web}/e2e/offline-queue-recovery.spec.ts (80%) create mode 100644 lib/e2e-web/package.json create mode 100644 lib/e2e-web/playwright.config.mjs create mode 100644 lib/e2e-web/tsconfig.json create mode 100644 packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationActions.ts create mode 100644 packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationState.ts create mode 100644 packages/web/web-sdk/src/entry-tracking/events/view/displayContentsViewLifecycle.ts create mode 100644 packages/web/web-sdk/src/entry-tracking/events/view/displayContentsViewSource.ts create mode 100644 packages/web/web-sdk/src/entry-tracking/events/view/elementViewSourceController.ts create mode 100644 packages/web/web-sdk/src/presentation/OptimizedEntryController.test.ts create mode 100644 packages/web/web-sdk/src/presentation/OptimizedEntryController.ts create mode 100644 packages/web/web-sdk/src/presentation/index.ts create mode 100644 packages/web/web-sdk/src/presentation/optimizationRootRuntime.ts create mode 100644 packages/web/web-sdk/src/web-components/ContentfulOptimizationRootElement.ts create mode 100644 packages/web/web-sdk/src/web-components/ContentfulOptimizedEntryElement.ts create mode 100644 packages/web/web-sdk/src/web-components/index.test.ts create mode 100644 packages/web/web-sdk/src/web-components/index.ts diff --git a/.github/workflows/main-pipeline.yaml b/.github/workflows/main-pipeline.yaml index 36acd7584..5d1aa0467 100644 --- a/.github/workflows/main-pipeline.yaml +++ b/.github/workflows/main-pipeline.yaml @@ -25,6 +25,7 @@ jobs: e2e_node_sdk_web_sdk: ${{ steps.filter.outputs.e2e_node_sdk_web_sdk }} e2e_web_sdk: ${{ steps.filter.outputs.e2e_web_sdk }} e2e_web_sdk_react: ${{ steps.filter.outputs.e2e_web_sdk_react }} + e2e_web_sdk_angular: ${{ steps.filter.outputs.e2e_web_sdk_angular }} e2e_react_web_sdk: ${{ steps.filter.outputs.e2e_react_web_sdk }} e2e_react_native_android: ${{ steps.filter.outputs.e2e_react_native_android }} e2e_android: ${{ steps.filter.outputs.e2e_android }} @@ -74,6 +75,11 @@ jobs: - '{implementations/web-sdk_react/**,lib/**,packages/web/web-sdk/**,packages/web/preview-panel/**,packages/universal/core-sdk/**,packages/universal/api-client/**,packages/universal/api-schemas/**,package.json,pnpm-lock.yaml,.github/workflows/main-pipeline.yaml}' - '!**/*.@(md|mdx|markdown)' - '!{docs/**,documentation/**,**/docs/**,**/documentation/**}' + # Angular + Web SDK implementation E2E coverage scope. + e2e_web_sdk_angular: + - '{implementations/web-sdk_angular/**,lib/**,packages/web/web-sdk/**,packages/web/preview-panel/**,packages/universal/core-sdk/**,packages/universal/api-client/**,packages/universal/api-schemas/**,package.json,pnpm-lock.yaml,.github/workflows/main-pipeline.yaml}' + - '!**/*.@(md|mdx|markdown)' + - '!{docs/**,documentation/**,**/docs/**,**/documentation/**}' # React Web SDK (optimization-react-web) implementation E2E coverage scope. e2e_react_web_sdk: - '{implementations/react-web-sdk/**,lib/**,packages/web/frameworks/react-web-sdk/**,packages/web/web-sdk/**,packages/web/preview-panel/**,packages/universal/core-sdk/**,packages/universal/api-client/**,packages/universal/api-schemas/**,package.json,pnpm-lock.yaml,.github/workflows/main-pipeline.yaml}' @@ -544,8 +550,8 @@ jobs: path: pkgs - run: pnpm store prune - run: pnpm run implementation:web-sdk_react -- implementation:install -- --no-frozen-lockfile - - run: - pnpm run implementation:web-sdk_react -- implementation:playwright:install -- --with-deps + - run: pnpm --dir lib/e2e-web install --no-frozen-lockfile + - run: pnpm --dir lib/e2e-web run setup:e2e - run: pnpm run implementation:web-sdk_react -- implementation:test:e2e:run - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -553,8 +559,56 @@ jobs: with: name: ci-results-web-sdk_react path: | - ./implementations/web-sdk_react/playwright-report/ - ./implementations/web-sdk_react/test-results/ + ./lib/e2e-web/playwright-report/ + ./lib/e2e-web/test-results/ + retention-days: 1 + + e2e-web-sdk_angular: + name: 🅰️ E2E Angular + Web SDK + runs-on: namespace-profile-linux-8-vcpu-16-gb-ram-optimal + timeout-minutes: 15 + needs: [setup, changes, build] + if: needs.changes.outputs.e2e_web_sdk_angular == 'true' + steps: + - uses: namespacelabs/nscloud-checkout-action@938f5d2d403d6224d9a0c0dc559b1dae09c2ede4 # v8.1.1 + + - name: Create .env from .env.example + run: cp implementations/web-sdk_angular/.env.example implementations/web-sdk_angular/.env + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: '.nvmrc' + package-manager-cache: false + + - uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3 + + - name: Set up caches (Namespace) + uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + with: + cache: | + pnpm + playwright + apt + + - run: pnpm install --prefer-offline --frozen-lockfile + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: sdk-package-tarballs + path: pkgs + - run: pnpm store prune + - run: + pnpm run implementation:web-sdk_angular -- implementation:install -- --no-frozen-lockfile + - run: pnpm --dir lib/e2e-web install --no-frozen-lockfile + - run: pnpm --dir lib/e2e-web run setup:e2e + - run: pnpm run implementation:web-sdk_angular -- implementation:test:e2e:run + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: ${{ !cancelled() }} + with: + name: ci-results-web-sdk_angular + path: | + ./lib/e2e-web/playwright-report/ + ./lib/e2e-web/test-results/ retention-days: 1 e2e-react-web-sdk: diff --git a/AGENTS.md b/AGENTS.md index 937ea2934..f57ae01c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,20 @@ Repository-wide baseline. Child files add local constraints; the nearest child f - Validate package and implementation changes in dependency order: source package typecheck/tests, source package build, `pnpm build:pkgs` when implementations consume it, implementation install, then downstream checks. +- Treat package build outputs as shared mutable state across all SDKs. Never manually run `build`, + `clean`, `build:pkgs`, implementation install, `size:report`, `size:check`, or any command that + reads, writes, removes, or packages generated artifacts in parallel with another package command + that can touch the same package or an upstream/downstream package in its dependency graph. +- `size:report` and `size:check` read generated package output and may depend on emitted chunks from + upstream packages. Serialize them with any build, clean, package, or size command for the package + being measured and every upstream or downstream SDK that can consume its output. +- Do not manually parallelize validation for packages with dependency edges between them. Prefer the + aggregate workspace command, such as `pnpm build`, `pnpm build:pkgs`, or `pnpm size:check`, when + the full graph is involved because pnpm can schedule workspace dependencies. When running narrowed + package commands yourself, run each dependency level to completion before starting dependents. +- Manual parallel commands are only appropriate for read-only inspection or checks that are + demonstrably independent and do not clean, rebuild, package, install, or measure generated package + artifacts. - For native, React Native, or E2E validation, use the implementation-specific runner documented in the nearest `AGENTS.md`, package scripts, or README before deciding the test cannot run locally. Missing attached devices, simulators, emulators, mock servers, or Metro are setup states; many diff --git a/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md b/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md index f5359ca0b..32fe960ee 100644 --- a/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md +++ b/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md @@ -186,12 +186,16 @@ Inside the provider tree, use hooks to interact with the SDK: import { useEntryResolver, useOptimization, + useOptimizationActions, useOptimizationContext, } from '@contentful/optimization-react-web' function MyComponent() { - const { consent, identify, page, track, getFlag } = useOptimization() + const { consent, identify, page, track } = useOptimizationActions() + const optimization = useOptimization() const { resolveEntry } = useEntryResolver() + + optimization.getFlag('hero-copy') // SDK is guaranteed to be ready here } @@ -293,10 +297,10 @@ When your application policy depends on user choice, call `consent()` from the b or account settings flow that owns the user's choice: ```tsx -import { useOptimization } from '@contentful/optimization-react-web' +import { useOptimizationActions } from '@contentful/optimization-react-web' function ConsentBanner() { - const { consent } = useOptimization() + const { consent } = useOptimizationActions() return (
@@ -348,14 +352,12 @@ function ConsentStatus() { To revoke consent after it was previously accepted: ```tsx -function RevokeConsent() { - const { consent } = useOptimization() +import { useOptimizationActions } from '@contentful/optimization-react-web' - const handleRevoke = () => { - consent(false) - } +function RevokeConsent() { + const { consent } = useOptimizationActions() - return + return } ``` diff --git a/implementations/web-sdk/README.md b/implementations/web-sdk/README.md index 76fc1ff0c..bf1197f34 100644 --- a/implementations/web-sdk/README.md +++ b/implementations/web-sdk/README.md @@ -27,8 +27,9 @@ This is a reference implementation for the ## What this demonstrates Use this implementation when you need the smallest browser example for the Web SDK without a -framework layer. It demonstrates a static HTML integration, local mock API usage, Web SDK asset -copying, and Playwright coverage for browser-side optimization and tracking behavior. +framework layer. It demonstrates a static HTML integration, Web Components entry rendering, local +mock API usage, Web SDK asset copying, and Playwright coverage for browser-side optimization, +tracking, live updates, consent gating, and offline recovery behavior. ## CDA locale handling diff --git a/implementations/web-sdk/e2e/displays-identified-user-variants.spec.ts b/implementations/web-sdk/e2e/displays-identified-user-variants.spec.ts index 7bd0f6f50..d5f83b50e 100644 --- a/implementations/web-sdk/e2e/displays-identified-user-variants.spec.ts +++ b/implementations/web-sdk/e2e/displays-identified-user-variants.spec.ts @@ -1,4 +1,8 @@ -import { expect, test } from '@playwright/test' +import { expect, test, type Locator, type Page } from '@playwright/test' + +function getRenderedEntries(page: Page): Locator { + return page.locator('#auto-observed, #manually-observed') +} test.describe('identified user', () => { test.beforeEach(async ({ page }) => { @@ -23,42 +27,50 @@ test.describe('identified user', () => { }) test('displays common variants', async ({ page }) => { + const renderedEntries = getRenderedEntries(page) + await expect( - page.getByText( + renderedEntries.getByText( 'This is a merge tag content entry that displays the visitor\'s continent "EU" embedded within the text.', ), ).toBeVisible() await expect( - page.getByText('This is a variant content entry for visitors from Europe.'), + renderedEntries.getByText('This is a variant content entry for visitors from Europe.'), ).toBeVisible() await expect( - page.getByText('This is a variant content entry for visitors using a desktop browser.'), + renderedEntries.getByText( + 'This is a variant content entry for visitors using a desktop browser.', + ), ).toBeVisible() }) test('displays identified user variants', async ({ page }) => { - await expect(page.getByText('This is a level 0 nested variant entry.')).toBeVisible() + const renderedEntries = getRenderedEntries(page) - await expect(page.getByText('This is a level 1 nested variant entry.')).toBeVisible() + await expect(renderedEntries.getByText('This is a level 0 nested variant entry.')).toBeVisible() - await expect(page.getByText('This is a level 2 nested variant entry.')).toBeVisible() + await expect(renderedEntries.getByText('This is a level 1 nested variant entry.')).toBeVisible() + + await expect(renderedEntries.getByText('This is a level 2 nested variant entry.')).toBeVisible() await expect( - page.getByText('This is a variant content entry for return visitors.'), + renderedEntries.getByText('This is a variant content entry for return visitors.'), ).toBeVisible() await expect( - page.getByText('This is a variant content entry for an A/B/C experiment: B'), + renderedEntries.getByText('This is a variant content entry for an A/B/C experiment: B'), ).toBeVisible() await expect( - page.getByText('This is a variant content entry for visitors with a custom event.'), + renderedEntries.getByText( + 'This is a variant content entry for visitors with a custom event.', + ), ).toBeVisible() await expect( - page.getByText('This is a variant content entry for identified users.'), + renderedEntries.getByText('This is a variant content entry for identified users.'), ).toBeVisible() }) }) diff --git a/implementations/web-sdk/e2e/displays-unidentified-user-variants.spec.ts b/implementations/web-sdk/e2e/displays-unidentified-user-variants.spec.ts index 59496cf9c..d2e889212 100644 --- a/implementations/web-sdk/e2e/displays-unidentified-user-variants.spec.ts +++ b/implementations/web-sdk/e2e/displays-unidentified-user-variants.spec.ts @@ -1,4 +1,8 @@ -import { expect, test } from '@playwright/test' +import { expect, test, type Locator, type Page } from '@playwright/test' + +function getRenderedEntries(page: Page): Locator { + return page.locator('#auto-observed, #manually-observed') +} test.describe('unidentified user', () => { test.beforeEach(async ({ page }) => { @@ -7,42 +11,58 @@ test.describe('unidentified user', () => { }) test('displays common variants', async ({ page }) => { + const renderedEntries = getRenderedEntries(page) + await expect( - page.getByText( + renderedEntries.getByText( 'This is a merge tag content entry that displays the visitor\'s continent "EU" embedded within the text.', ), ).toBeVisible() await expect( - page.getByText('This is a variant content entry for visitors from Europe.'), + renderedEntries.getByText('This is a variant content entry for visitors from Europe.'), ).toBeVisible() await expect( - page.getByText('This is a variant content entry for visitors using a desktop browser.'), + renderedEntries.getByText( + 'This is a variant content entry for visitors using a desktop browser.', + ), ).toBeVisible() }) test('displays unidentified user variants', async ({ page }) => { - await expect(page.getByText('This is a level 0 nested baseline entry.')).toBeVisible() + const renderedEntries = getRenderedEntries(page) - await expect(page.getByText('This is a level 1 nested baseline entry.')).toBeVisible() + await expect( + renderedEntries.getByText('This is a level 0 nested baseline entry.'), + ).toBeVisible() - await expect(page.getByText('This is a level 2 nested baseline entry.')).toBeVisible() + await expect( + renderedEntries.getByText('This is a level 1 nested baseline entry.'), + ).toBeVisible() - await expect(page.getByText('This is a variant content entry for new visitors.')).toBeVisible() + await expect( + renderedEntries.getByText('This is a level 2 nested baseline entry.'), + ).toBeVisible() + + await expect( + renderedEntries.getByText('This is a variant content entry for new visitors.'), + ).toBeVisible() await expect( - page.getByText('This is a variant content entry for an A/B/C experiment: B'), + renderedEntries.getByText('This is a variant content entry for an A/B/C experiment: B'), ).toBeVisible() await expect( - page.getByText( + renderedEntries.getByText( 'This is a baseline content entry for all visitors with or without a custom event.', ), ).toBeVisible() await expect( - page.getByText('This is a baseline content entry for all identified or unidentified users.'), + renderedEntries.getByText( + 'This is a baseline content entry for all identified or unidentified users.', + ), ).toBeVisible() }) }) diff --git a/implementations/web-sdk/e2e/entry-click-tracking.spec.ts b/implementations/web-sdk/e2e/entry-click-tracking.spec.ts index a5f3efc27..2d5a86e4f 100644 --- a/implementations/web-sdk/e2e/entry-click-tracking.spec.ts +++ b/implementations/web-sdk/e2e/entry-click-tracking.spec.ts @@ -10,7 +10,7 @@ const clickScenarios: ClickScenario[] = [ { name: 'direct entry button', entryTestId: 'entry-click-direct-entry', - clickTargetTestId: 'entry-click-direct-entry', + clickTargetTestId: 'content-4ib0hsHWoSOnCVdDkizE8d', }, { name: 'clickable descendant button', @@ -65,8 +65,8 @@ test.describe('entry click tracking', () => { page, }) => { for (const scenario of clickScenarios) { - const entryLocator = page.getByTestId(scenario.entryTestId) - await expect(entryLocator, `${scenario.name}: entry should render`).toBeVisible() + const target = page.getByTestId(scenario.clickTargetTestId) + await expect(target, `${scenario.name}: click target should render`).toBeVisible() await expect .poll(async () => await readResolvedEntryId(page, scenario.entryTestId), { @@ -75,7 +75,6 @@ test.describe('entry click tracking', () => { .not.toEqual('') const resolvedEntryId = await readResolvedEntryId(page, scenario.entryTestId) - const target = page.getByTestId(scenario.clickTargetTestId) await target.scrollIntoViewIfNeeded() await target.click() diff --git a/implementations/web-sdk/e2e/entry-hover-tracking.spec.ts b/implementations/web-sdk/e2e/entry-hover-tracking.spec.ts index f3ffec17f..c6992b83a 100644 --- a/implementations/web-sdk/e2e/entry-hover-tracking.spec.ts +++ b/implementations/web-sdk/e2e/entry-hover-tracking.spec.ts @@ -10,7 +10,7 @@ const hoverScenarios: HoverScenario[] = [ { name: 'direct entry button', entryTestId: 'entry-click-direct-entry', - hoverTargetTestId: 'entry-click-direct-entry', + hoverTargetTestId: 'content-4ib0hsHWoSOnCVdDkizE8d', }, { name: 'hoverable descendant button', @@ -20,7 +20,7 @@ const hoverScenarios: HoverScenario[] = [ { name: 'inline entry nested in clickable ancestor', entryTestId: 'entry-click-ancestor-entry', - hoverTargetTestId: 'entry-click-ancestor-entry', + hoverTargetTestId: 'content-2Z2WLOx07InSewC3LUB3eX', }, ] @@ -81,10 +81,9 @@ test.describe('entry hover tracking', () => { const hoverButtons = getHoverButtons(page) for (const scenario of hoverScenarios) { - const entryLocator = page.getByTestId(scenario.entryTestId) - await expect(entryLocator, `${scenario.name}: entry should render`).toBeVisible() - const target = page.getByTestId(scenario.hoverTargetTestId) + await expect(target, `${scenario.name}: hover target should render`).toBeVisible() + const baselineHoverEventCount = await hoverButtons.count() await target.scrollIntoViewIfNeeded() diff --git a/implementations/web-sdk/e2e/entry-view-tracking.spec.ts b/implementations/web-sdk/e2e/entry-view-tracking.spec.ts index e49de6b3d..d61004a05 100644 --- a/implementations/web-sdk/e2e/entry-view-tracking.spec.ts +++ b/implementations/web-sdk/e2e/entry-view-tracking.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test' +import { expect, test, type Locator, type Page } from '@playwright/test' const variantEntryTexts: Record = { '1JAU028vQ7v6nB2swl3NBo': 'This is a level 0 nested baseline entry.', @@ -16,6 +16,12 @@ const variantEntryTexts: Record = { 'This is a baseline content entry for all identified or unidentified users.', } +const MANUAL_VIEW_BASELINE_ENTRY_ID = '5XHssysWUDECHzKLzoIsg1' + +function getRenderedEntries(page: Page): Locator { + return page.locator('#auto-observed, #manually-observed') +} + test.describe('entry view tracking', () => { test.describe('without consent', () => { test.beforeEach(async ({ page }) => { @@ -31,8 +37,10 @@ test.describe('entry view tracking', () => { }) test('entry view events have not been emitted', async ({ page }) => { + const renderedEntries = getRenderedEntries(page) + for (const entryText of Object.values(variantEntryTexts)) { - const element = page.getByText(entryText) + const element = renderedEntries.getByText(entryText) await element.scrollIntoViewIfNeeded() @@ -60,12 +68,14 @@ test.describe('entry view tracking', () => { }) test('entry view events have been emitted', async ({ page }) => { + const renderedEntries = getRenderedEntries(page) + for (const entryId of Object.keys(variantEntryTexts)) { const entryText = variantEntryTexts[entryId] if (!entryText) continue - const element = page.getByText(entryText) + const element = renderedEntries.getByText(entryText) await element.scrollIntoViewIfNeeded() @@ -97,5 +107,27 @@ test.describe('entry view tracking', () => { expect(new Set(viewIds).size).toEqual(viewIds.length) }) + + test('manual view example emits one view event without auto double tracking', async ({ + page, + }) => { + const manualEntry = page.getByTestId('manual-view-entry') + + await expect(manualEntry).toHaveAttribute('track-views', 'false') + await expect(manualEntry).toHaveAttribute('data-ctfl-track-views', 'false') + await expect(manualEntry).toHaveAttribute('data-ctfl-entry-id', /.+/) + + const resolvedEntryId = await manualEntry.getAttribute('data-ctfl-entry-id') + expect(resolvedEntryId).not.toBeNull() + + await page.getByTestId(`content-${MANUAL_VIEW_BASELINE_ENTRY_ID}`).scrollIntoViewIfNeeded() + await page.clock.fastForward('02:00') + + await expect( + page.locator( + `#event-stream li button[data-component-id="${resolvedEntryId}"][data-view-id]`, + ), + ).toHaveCount(1) + }) }) }) diff --git a/implementations/web-sdk/e2e/events-consent-gating.spec.ts b/implementations/web-sdk/e2e/events-consent-gating.spec.ts new file mode 100644 index 000000000..a026a5677 --- /dev/null +++ b/implementations/web-sdk/e2e/events-consent-gating.spec.ts @@ -0,0 +1,41 @@ +import { type Page, expect, test } from '@playwright/test' + +async function scrollThroughEntries(page: Page): Promise { + const entries = page.locator('[data-testid^="content-"]') + const entryCount = await entries.count() + + for (let index = 0; index < entryCount; index += 1) { + await entries.nth(index).scrollIntoViewIfNeeded() + } +} + +test.describe('consent gating', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('domcontentloaded') + await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() + }) + + test('allows page events without consent but gates entry view events', async ({ page }) => { + const pageEvents = page.locator('[data-testid^="event-page-"]') + const viewEvents = page.locator('[data-testid^="event-view-"]') + + await expect(pageEvents.first()).toBeVisible() + + await scrollThroughEntries(page) + await expect(viewEvents).toHaveCount(0) + }) + + test('emits entry view events after consent is accepted', async ({ page }) => { + const pageEvents = page.locator('[data-testid^="event-page-"]') + const viewEvents = page.locator('[data-testid^="event-view-"]') + + await expect(pageEvents.first()).toBeVisible() + + await page.getByTestId('consent-button').click() + await expect(page.getByTestId('consent-status')).toHaveText('Consent: true') + await scrollThroughEntries(page) + + await expect.poll(async () => await viewEvents.count()).toBeGreaterThan(0) + }) +}) diff --git a/implementations/web-sdk/e2e/flag-view-tracking.spec.ts b/implementations/web-sdk/e2e/flag-view-tracking.spec.ts index 64780b239..428c3f2dd 100644 --- a/implementations/web-sdk/e2e/flag-view-tracking.spec.ts +++ b/implementations/web-sdk/e2e/flag-view-tracking.spec.ts @@ -10,22 +10,43 @@ 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('heading', { name: 'Utilities' })).toBeVisible() }) - test('flag access emits a flag view event', async ({ page }) => { + test('does not emit flag view events without consent', async ({ page }) => { const flagAccessEvents = getFlagAccessEvents(page) - const baselineFlagEventCount = await flagAccessEvents.count() + + await expect(flagAccessEvents).toHaveCount(0) await page.getByRole('button', { name: 'Identify' }).click() await expect(page.getByRole('button', { name: 'Reset Profile' })).toBeVisible() + await expect(flagAccessEvents).toHaveCount(0) + }) + + test('emits flag view events after consent and profile updates', async ({ page }) => { + const flagAccessEvents = getFlagAccessEvents(page) + const baselineFlagEventCount = await flagAccessEvents.count() + + await page.getByRole('button', { name: 'Accept Consent' }).click() + await expect .poll(async () => await flagAccessEvents.count(), { - message: 'flag access should append a flag view event in the event stream', + message: 'consented flag subscription should append a flag view event', }) .toBeGreaterThan(baselineFlagEventCount) + const afterConsentFlagEventCount = 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: 'profile updates should append additional flag view events', + }) + .toBeGreaterThan(afterConsentFlagEventCount) + const latestFlagAccessEvent = flagAccessEvents.last() await expect(latestFlagAccessEvent).toHaveText('component') diff --git a/implementations/web-sdk_react/e2e/live-updates.spec.ts b/implementations/web-sdk/e2e/live-updates.spec.ts similarity index 81% rename from implementations/web-sdk_react/e2e/live-updates.spec.ts rename to implementations/web-sdk/e2e/live-updates.spec.ts index be4a212e2..fa1671efb 100644 --- a/implementations/web-sdk_react/e2e/live-updates.spec.ts +++ b/implementations/web-sdk/e2e/live-updates.spec.ts @@ -1,7 +1,5 @@ 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() @@ -12,6 +10,10 @@ async function identify(page: Page): Promise { await expect(page.getByTestId('identified-status')).toHaveText('Yes') } +function getPreviewPanelToggle(page: Page): Locator { + return page.locator('ctfl-opt-preview-panel').locator('button.toggle-drawer') +} + test.describe('live updates behavior', () => { test.beforeEach(async ({ page }) => { await page.context().clearCookies() @@ -21,13 +23,7 @@ test.describe('live updates behavior', () => { await expect(page.getByTestId('preview-panel-status')).toHaveText('Closed') await expect(page.getByTestId('global-live-updates-status')).toHaveText('OFF') await expect(page.getByTestId('identified-status')).toHaveText('No') - if (isPreviewPanelEnabled) { - await expect - .poll(async () => await page.locator('ctfl-opt-preview-panel').count()) - .toBeGreaterThan(0) - } else { - await expect(page.locator('ctfl-opt-preview-panel')).toHaveCount(0) - } + await expect(page.locator('ctfl-opt-preview-panel')).toHaveCount(1) await expect(page.getByTestId('live-updates-examples')).toBeVisible() await expect .poll(async () => { @@ -50,7 +46,7 @@ test.describe('live updates behavior', () => { ) }) - test('global live updates ON updates the default entry while the locked entry stays fixed', async ({ + test('global live updates ON updates default component while locked component stays fixed', async ({ page, }) => { await page.getByTestId('toggle-global-live-updates-button').click() @@ -69,7 +65,7 @@ test.describe('live updates behavior', () => { ) }) - test('per-component liveUpdates=true updates even when global live updates is OFF', async ({ + test('per-component live-updates=true updates even when global live updates is OFF', async ({ page, }) => { const initialLiveEntryId = await getEntryId(page.getByTestId('entry-id-live-enabled')) @@ -81,11 +77,10 @@ test.describe('live updates behavior', () => { .not.toBe(initialLiveEntryId) }) - test('preview-panel override enables updates for locked entries', async ({ page }) => { - test.skip(!isPreviewPanelEnabled, 'Preview panel is disabled for this build.') + test('preview-panel override enables updates for locked components', async ({ page }) => { const initialLockedEntryId = await getEntryId(page.getByTestId('entry-id-live-locked')) - await page.getByTestId('simulate-preview-panel-button').click() + await getPreviewPanelToggle(page).click() await expect(page.getByTestId('preview-panel-status')).toHaveText('Open') await identify(page) @@ -95,18 +90,19 @@ test.describe('live updates behavior', () => { .not.toBe(initialLockedEntryId) }) - test('screen controls toggle global live updates and preview panel', async ({ page }) => { - test.skip(!isPreviewPanelEnabled, 'Preview panel is disabled for this build.') + test('screen controls toggle global live updates', async ({ page }) => { await expect(page.getByTestId('global-live-updates-status')).toHaveText('OFF') await page.getByTestId('toggle-global-live-updates-button').click() await expect(page.getByTestId('global-live-updates-status')).toHaveText('ON') await page.getByTestId('toggle-global-live-updates-button').click() await expect(page.getByTestId('global-live-updates-status')).toHaveText('OFF') + }) + test('built-in preview panel toggle opens and closes the panel', async ({ page }) => { await expect(page.getByTestId('preview-panel-status')).toHaveText('Closed') - await page.getByTestId('simulate-preview-panel-button').click() + await getPreviewPanelToggle(page).click() await expect(page.getByTestId('preview-panel-status')).toHaveText('Open') - await page.getByTestId('simulate-preview-panel-button').click() + await getPreviewPanelToggle(page).click() await expect(page.getByTestId('preview-panel-status')).toHaveText('Closed') }) diff --git a/implementations/web-sdk/e2e/offline-queue-recovery.spec.ts b/implementations/web-sdk/e2e/offline-queue-recovery.spec.ts new file mode 100644 index 000000000..f8317a81f --- /dev/null +++ b/implementations/web-sdk/e2e/offline-queue-recovery.spec.ts @@ -0,0 +1,88 @@ +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) +} + +async function expectRawEventsToIncrease(page: Page, baselineCount: number): Promise { + await expect.poll(async () => await getRawEventsCount(page)).toBeGreaterThan(baselineCount) +} + +async function setOffline(context: BrowserContext, offline: boolean): Promise { + await context.setOffline(offline) +} + +async function waitForBaseUi(page: Page): Promise { + await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() + await expect(page.getByTestId('events-count')).toBeVisible() + await expect(page.getByTestId('raw-events-count')).toBeVisible() + await expect(page.getByTestId('live-updates-identify-button')).toBeVisible() +} + +test.describe('offline queue and recovery', () => { + test.beforeEach(async ({ context, page }) => { + await context.clearCookies() + await setOffline(context, false) + await page.goto('/') + await page.waitForLoadState('domcontentloaded') + await waitForBaseUi(page) + }) + + test.afterEach(async ({ context }) => { + await setOffline(context, false) + }) + + test('continues tracking consented custom events while offline', async ({ context, page }) => { + await page.getByTestId('consent-button').click() + const baselineCount = await getRawEventsCount(page) + + await setOffline(context, true) + await page.getByTestId('manual-track-button').click() + await expectRawEventsToIncrease(page, baselineCount) + }) + + test('recovers gracefully when network is restored', async ({ context, page }) => { + await setOffline(context, true) + await page.getByTestId('live-updates-identify-button').click() + 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('identified-status')).toHaveText('Yes') + }) + + test('remains stable across rapid network state changes', async ({ context, page }) => { + await setOffline(context, true) + await setOffline(context, false) + await setOffline(context, true) + await setOffline(context, false) + + await waitForBaseUi(page) + await page.getByTestId('consent-button').click() + const baselineCount = await getRawEventsCount(page) + await page.getByTestId('manual-track-button').click() + await expectRawEventsToIncrease(page, baselineCount) + }) + + test('queues identify event offline and updates identified state when online', async ({ + context, + page, + }) => { + const baselineCount = await getRawEventsCount(page) + + await setOffline(context, true) + await page.getByTestId('live-updates-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('identified-status')).toHaveText('Yes') + }) +}) diff --git a/implementations/web-sdk/e2e/preview-panel.spec.ts b/implementations/web-sdk/e2e/preview-panel.spec.ts index 8fed0d511..056666fbc 100644 --- a/implementations/web-sdk/e2e/preview-panel.spec.ts +++ b/implementations/web-sdk/e2e/preview-panel.spec.ts @@ -12,7 +12,11 @@ async function selectPreviewVariant( optimization: Locator, variantLabel: 'Baseline' | 'Variant 1', ): Promise { - await optimization.getByLabel(variantLabel).evaluate((input: { click: () => void }) => { + await optimization.getByLabel(variantLabel).evaluate((input) => { + if (!(input instanceof HTMLElement)) { + throw new Error(`Expected "${variantLabel}" control to be an HTML element.`) + } + input.click() }) } diff --git a/implementations/web-sdk/e2e/web-components-lifecycle.spec.ts b/implementations/web-sdk/e2e/web-components-lifecycle.spec.ts new file mode 100644 index 000000000..5096696d2 --- /dev/null +++ b/implementations/web-sdk/e2e/web-components-lifecycle.spec.ts @@ -0,0 +1,499 @@ +import { expect, test, type Locator, type Page } from '@playwright/test' + +const MANUAL_VIEW_ENTRY_SELECTOR = '[data-testid="manual-view-entry"]' + +interface HostSnapshot { + readonly baselineId: string | null + readonly entryId: string | null + readonly optimizationId: string | null + readonly sticky: string | null + readonly variantIndex: string | null + readonly visibility: string +} + +async function waitForReferenceEntry(page: Page): Promise { + const entry = page.locator(MANUAL_VIEW_ENTRY_SELECTOR) + + await expect(entry).toHaveAttribute('data-ctfl-entry-id', /.+/) + await page.waitForFunction((selector) => { + const element = document.querySelector(selector) + + return ( + element instanceof HTMLElement && + 'baselineEntry' in element && + element.baselineEntry !== undefined + ) + }, MANUAL_VIEW_ENTRY_SELECTOR) + + return entry +} + +test.describe('web component lifecycle', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('domcontentloaded') + await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() + await waitForReferenceEntry(page) + }) + + test('clears host presentation state when baselineEntry is unset and resolves again', async ({ + page, + }) => { + const resetResult = await page.locator(MANUAL_VIEW_ENTRY_SELECTOR).evaluate(async (node) => { + interface OptimizedEntryElement extends HTMLElement { + baselineEntry?: unknown + sdk?: unknown + } + + interface ObservableValue { + readonly current: T + readonly subscribe: (next: (value: T) => void) => { unsubscribe: () => void } + readonly subscribeOnce: (next: (value: NonNullable) => void) => { + unsubscribe: () => void + } + } + + function isOptimizedEntryElement(value: unknown): value is OptimizedEntryElement { + return value instanceof HTMLElement && 'baselineEntry' in value + } + + function isResolvedEntryDetail( + value: unknown, + ): value is { readonly entry: { readonly sys: { readonly id: string } } } { + return ( + typeof value === 'object' && + value !== null && + 'entry' in value && + typeof value.entry === 'object' && + value.entry !== null && + 'sys' in value.entry && + typeof value.entry.sys === 'object' && + value.entry.sys !== null && + 'id' in value.entry.sys && + typeof value.entry.sys.id === 'string' + ) + } + + function snapshot(element: HTMLElement): HostSnapshot { + return { + baselineId: element.getAttribute('data-ctfl-baseline-id'), + entryId: element.getAttribute('data-ctfl-entry-id'), + optimizationId: element.getAttribute('data-ctfl-optimization-id'), + sticky: element.getAttribute('data-ctfl-sticky'), + variantIndex: element.getAttribute('data-ctfl-variant-index'), + visibility: element.style.visibility, + } + } + + function createObservable(current: T): ObservableValue { + return { + get current() { + return current + }, + subscribe(next) { + next(current) + + return { unsubscribe: () => undefined } + }, + subscribeOnce(next) { + if (current !== undefined && current !== null) { + next(current) + } + + return { unsubscribe: () => undefined } + }, + } + } + + if (!isOptimizedEntryElement(node)) { + throw new Error('Expected the reference element to be a ctfl-optimized-entry.') + } + + const element = node + const baselineEntry = element.baselineEntry + if (!baselineEntry) { + throw new Error('Expected the reference entry to have a baselineEntry.') + } + + const resolvedEntryIds: string[] = [] + const beforeUnset = snapshot(element) + + element.addEventListener('ctfl-entry-resolved', (event) => { + if (!(event instanceof CustomEvent)) return + + const detail: unknown = event.detail + if (isResolvedEntryDetail(detail)) { + resolvedEntryIds.push(detail.entry.sys.id) + } + }) + + element.baselineEntry = undefined + const afterUnset = snapshot(element) + + element.baselineEntry = baselineEntry + await new Promise((resolve) => { + requestAnimationFrame(resolve) + }) + const afterReset = snapshot(element) + + const loadingElement = document.createElement('ctfl-optimized-entry') + if (!isOptimizedEntryElement(loadingElement)) { + throw new Error('Expected the dynamic element to be a ctfl-optimized-entry.') + } + + loadingElement.sdk = { + destroy: () => undefined, + resolveOptimizedEntry: (entry: unknown) => ({ entry, selectedOptimization: undefined }), + setLocale: () => undefined, + states: { + canOptimize: createObservable(false), + experienceRequestState: createObservable({ status: 'idle' }), + previewPanelOpen: createObservable(false), + selectedOptimizations: createObservable(undefined), + }, + } + loadingElement.baselineEntry = baselineEntry + document.body.append(loadingElement) + const loadingBeforeUnset = snapshot(loadingElement) + loadingElement.baselineEntry = undefined + const loadingAfterUnset = snapshot(loadingElement) + loadingElement.remove() + + return { + afterReset, + afterUnset, + beforeUnset, + loadingAfterUnset, + loadingBeforeUnset, + resolvedEntryIds, + } + }) + + expect(resetResult.beforeUnset).toMatchObject({ + baselineId: '5XHssysWUDECHzKLzoIsg1', + entryId: '4bmHsNUaEibELHwWCon3dt', + optimizationId: expect.any(String), + sticky: expect.any(String), + variantIndex: expect.any(String), + }) + expect(resetResult.afterUnset).toEqual({ + baselineId: null, + entryId: null, + optimizationId: null, + sticky: null, + variantIndex: null, + visibility: '', + }) + expect(resetResult.resolvedEntryIds).toContain(resetResult.beforeUnset.entryId) + expect(resetResult.afterReset).toEqual(resetResult.beforeUnset) + expect(resetResult.loadingBeforeUnset).toMatchObject({ + baselineId: null, + entryId: null, + optimizationId: null, + sticky: null, + variantIndex: null, + visibility: 'hidden', + }) + expect(resetResult.loadingAfterUnset).toEqual({ + baselineId: null, + entryId: null, + optimizationId: null, + sticky: null, + variantIndex: null, + visibility: '', + }) + }) + + test('auto-binds optimized entries under custom registered root tags', async ({ page }) => { + const result = await page.evaluate(async (manualViewEntrySelector) => { + interface OptimizedEntryElement extends HTMLElement { + baselineEntry?: unknown + readonly root?: unknown + } + + interface RootElement extends HTMLElement { + sdk?: unknown + } + + interface TestWindow extends Window { + ContentfulOptimizationWebComponents: { + defineContentfulOptimizationElements: (options: { + optimizedEntryTagName: string + rootTagName: string + }) => void + } + contentfulOptimization: unknown + } + + function isOptimizedEntryElement(value: unknown): value is OptimizedEntryElement { + return value instanceof HTMLElement && 'baselineEntry' in value + } + + function isRootElement(value: unknown): value is RootElement { + return value instanceof HTMLElement && 'sdk' in value + } + + function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null + } + + function hasReferenceRuntime(value: Window): value is TestWindow { + const components = + 'ContentfulOptimizationWebComponents' in value + ? value.ContentfulOptimizationWebComponents + : undefined + + return ( + isRecord(components) && + typeof components.defineContentfulOptimizationElements === 'function' && + 'contentfulOptimization' in value && + value.contentfulOptimization !== undefined + ) + } + + if (!hasReferenceRuntime(window)) { + throw new Error('Expected the reference Web SDK runtime to be initialized.') + } + + const testWindow = window + const components = testWindow.ContentfulOptimizationWebComponents + const sdk = testWindow.contentfulOptimization + const source = document.querySelector(manualViewEntrySelector) + if (!isOptimizedEntryElement(source) || !source.baselineEntry) { + throw new Error('Expected the reference Web SDK runtime to be initialized.') + } + + const rootTagName = 'ctfl-e2e-optimization-root' + const optimizedEntryTagName = 'ctfl-e2e-optimized-entry' + components.defineContentfulOptimizationElements({ optimizedEntryTagName, rootTagName }) + + const root = document.createElement(rootTagName) + const entry = document.createElement(optimizedEntryTagName) + const fixture = document.createElement('div') + if (!isRootElement(root) || !isOptimizedEntryElement(entry)) { + throw new Error('Expected custom elements to be registered.') + } + + fixture.dataset.testid = 'custom-tag-fixture' + root.sdk = sdk + entry.baselineEntry = source.baselineEntry + root.append(entry) + fixture.append(root) + document.body.append(fixture) + + await new Promise((resolve) => { + requestAnimationFrame(resolve) + }) + + const result = { + explicitRootWasUnset: entry.root === undefined, + resolvedEntryId: entry.getAttribute('data-ctfl-entry-id'), + rootTagName: root.tagName.toLowerCase(), + tagName: entry.tagName.toLowerCase(), + } + + fixture.remove() + + return result + }, MANUAL_VIEW_ENTRY_SELECTOR) + + expect(result).toEqual({ + explicitRootWasUnset: true, + resolvedEntryId: '4bmHsNUaEibELHwWCon3dt', + rootTagName: 'ctfl-e2e-optimization-root', + tagName: 'ctfl-e2e-optimized-entry', + }) + }) + + test('preserves initial preview-panel-open state for late-created roots', async ({ page }) => { + const result = await page.evaluate(async (manualViewEntrySelector) => { + interface Signal { + value: T + } + + interface SelectedOptimization { + readonly experienceId: string + readonly sticky?: boolean + readonly variantIndex: number + readonly variants?: Record + } + + interface PreviewSignals { + readonly previewPanelOpen: Signal + readonly selectedOptimizations: Signal + } + + interface OptimizedEntryElement extends HTMLElement { + baselineEntry?: unknown + } + + interface RootElement extends HTMLElement { + sdk?: unknown + } + + interface TestSdk { + readonly states: { + readonly selectedOptimizations: { readonly current: SelectedOptimization[] | undefined } + } + readonly registerPreviewPanel: (target: Record) => void + } + + interface TestWindow extends Window { + contentfulOptimization: TestSdk + } + + function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null + } + + function isOptimizedEntryElement(value: unknown): value is OptimizedEntryElement { + return value instanceof HTMLElement && 'baselineEntry' in value + } + + function isRootElement(value: unknown): value is RootElement { + return value instanceof HTMLElement && 'sdk' in value + } + + function isSignal(value: unknown): value is Signal { + return isRecord(value) && 'value' in value + } + + function isPreviewSignals(value: unknown): value is PreviewSignals { + return ( + isRecord(value) && + isSignal(value.previewPanelOpen) && + isSignal(value.selectedOptimizations) + ) + } + + function hasReferenceSdk(value: Window): value is TestWindow { + return ( + 'contentfulOptimization' in value && + isRecord(value.contentfulOptimization) && + 'states' in value.contentfulOptimization && + isRecord(value.contentfulOptimization.states) && + 'selectedOptimizations' in value.contentfulOptimization.states && + isRecord(value.contentfulOptimization.states.selectedOptimizations) && + 'current' in value.contentfulOptimization.states.selectedOptimizations && + typeof value.contentfulOptimization.registerPreviewPanel === 'function' + ) + } + + function readEntryId(entry: unknown): string { + if (!isRecord(entry) || !isRecord(entry.sys) || typeof entry.sys.id !== 'string') { + throw new Error('Expected the baseline entry to have an ID.') + } + + return entry.sys.id + } + + function snapshot(element: HTMLElement): HostSnapshot { + return { + baselineId: element.getAttribute('data-ctfl-baseline-id'), + entryId: element.getAttribute('data-ctfl-entry-id'), + optimizationId: element.getAttribute('data-ctfl-optimization-id'), + sticky: element.getAttribute('data-ctfl-sticky'), + variantIndex: element.getAttribute('data-ctfl-variant-index'), + visibility: element.style.visibility, + } + } + + if (!hasReferenceSdk(window)) { + throw new Error('Expected the reference Web SDK runtime to be initialized.') + } + + const sdk = window.contentfulOptimization + const source = document.querySelector(manualViewEntrySelector) + if (!isOptimizedEntryElement(source) || !source.baselineEntry) { + throw new Error('Expected the reference Web SDK runtime to be initialized.') + } + + const baselineEntryId = readEntryId(source.baselineEntry) + + const signalTarget: Record = {} + sdk.registerPreviewPanel(signalTarget) + const signals = Reflect.get(signalTarget, Symbol.for('ctfl.optimization.preview.signals')) + if (!isPreviewSignals(signals)) { + throw new Error('Expected preview panel signals to be registered.') + } + + const selectedOptimizations = sdk.states.selectedOptimizations.current ?? [] + const selectedOptimization = selectedOptimizations.find( + (optimization) => optimization.variants?.[baselineEntryId] !== undefined, + ) + if (!selectedOptimization) { + throw new Error('Expected a selected optimization for the baseline entry.') + } + + signals.previewPanelOpen.value = true + + const root = document.createElement('ctfl-optimization-root') + const entry = document.createElement('ctfl-optimized-entry') + const fixture = document.createElement('div') + if (!isRootElement(root) || !isOptimizedEntryElement(entry)) { + throw new Error('Expected default Web Components to be registered.') + } + + fixture.dataset.testid = 'preview-open-fixture' + root.sdk = sdk + entry.setAttribute('live-updates', 'false') + entry.baselineEntry = source.baselineEntry + root.append(entry) + fixture.append(root) + document.body.append(fixture) + + await new Promise((resolve) => { + requestAnimationFrame(resolve) + }) + + const beforeUpdate = snapshot(entry) + const nextVariantIndex = selectedOptimization.variantIndex === 0 ? 1 : 0 + const expectedEntryId = + nextVariantIndex === 0 ? baselineEntryId : selectedOptimization.variants?.[baselineEntryId] + + const resolvedAfterUpdate = new Promise((resolve) => { + const timeout = window.setTimeout(() => { + resolve(false) + }, 1000) + + entry.addEventListener( + 'ctfl-entry-resolved', + () => { + window.clearTimeout(timeout) + resolve(true) + }, + { once: true }, + ) + }) + + signals.selectedOptimizations.value = selectedOptimizations.map((optimization) => + optimization.experienceId === selectedOptimization.experienceId + ? { ...optimization, variantIndex: nextVariantIndex } + : optimization, + ) + + const didResolveAfterUpdate = await resolvedAfterUpdate + const afterUpdate = snapshot(entry) + + fixture.remove() + signals.previewPanelOpen.value = false + signals.selectedOptimizations.value = selectedOptimizations + + return { + afterUpdate, + beforeUpdate, + didResolveAfterUpdate, + expectedEntryId, + nextVariantIndex, + previewPanelOpenAtCreation: true, + } + }, MANUAL_VIEW_ENTRY_SELECTOR) + + expect(result.previewPanelOpenAtCreation).toBe(true) + expect(result.beforeUpdate.entryId).toBe('4bmHsNUaEibELHwWCon3dt') + expect(result.didResolveAfterUpdate).toBe(true) + expect(result.afterUpdate.entryId).toBe(result.expectedEntryId) + expect(result.afterUpdate.variantIndex).toBe(String(result.nextVariantIndex)) + expect(result.afterUpdate.entryId).not.toBe(result.beforeUpdate.entryId) + }) +}) diff --git a/implementations/web-sdk/public/index.html b/implementations/web-sdk/public/index.html index c047de188..4fd96627f 100644 --- a/implementations/web-sdk/public/index.html +++ b/implementations/web-sdk/public/index.html @@ -6,6 +6,7 @@ ContentfulOptimization Web SDK Vanilla JS Implementation E2E Test +

ContentfulOptimization Web SDK Dev Development Dashboard

-
-
-

Utilities

- - - - - - | - - - - - | - - -

-          
- + +
+
+

Utilities

+ + + + + + | + + + + + | + + +

+            
+              
+            
+          
+ | + + +

+            
+ +
+
+
+ +
+

Entry Data

+ +
+
+ Inspect Contentful Entry + + +
-
- | - - -

-          
- -
-
-
- -
-

Entry Data

- -
-
- Resolve Contentful Entry - - -
-
- -
- Entry Data -
    -
    -
    - -
    -
    -

    Event Stream

    - -
      -
      -
      - -
      -

      Entries

      - -
      -
      - + +
      + Entry Data +
        +
        +
        + +
        +
        +

        Event Stream

        + +
          -
          - +
          + +
          +

          Entries

          + +
          + + + + + + + + + + +
          -
          -
          -
          -
          - -
          -
          -
          -
          -
          - - + +
          + +
          + + +