From f513f954d9fd791cff1d60968ddc07fb39ebc503 Mon Sep 17 00:00:00 2001 From: Charles Hudson Date: Tue, 16 Jun 2026 12:00:19 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A5=20feat(rendering):=20Handle=20rend?= =?UTF-8?q?ering=20concerns=20framework-agnostic,=20prove=20with=20Web=20c?= =?UTF-8?q?omponents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Description** ## Summary This introduces a framework-agnostic presentation layer for optimized entry rendering and uses it from both the React Web SDK and the new Web Components surface. Key changes: - Add `@contentful/optimization-web/presentation` with shared optimized-entry snapshot logic, loading/fallback behavior, live-update locking, host tracking attributes, duplicate-baseline nesting state, and optimization root SDK binding helpers. - Add `@contentful/optimization-web/web-components` with `ctfl-optimization-root`, `ctfl-optimized-entry`, and `defineContentfulOptimizationElements`. - Refactor React `OptimizedEntry` to consume the shared presentation controller instead of duplicating presentation, loading, tracking-attribute, and live-update logic. - Keep optimized entry hosts as `display: contents` wrappers, with tracking attributes applied to the host. - Update the Web SDK dev harness and Vanilla reference implementation to use Web Components as the primary integration pattern. - Expand Vanilla Web SDK E2E coverage for Web Components-based display, click, hover, view, consent, preview, live updates, offline recovery, and manual view tracking. - Keep one lean manual-tracking example with coverage proving it does not double-track. - Update bundle-size tooling so JavaScript entry budgets include reachable local split chunks, then adjust affected budgets accordingly. ## Notes for reviewers - Web Components intentionally do not fetch or render entry content. They bind SDK state, host tracking attributes, and lifecycle events; the consumer renders content in response to resolved entry data. - `data-ctfl-entry-id` continues to represent the currently rendered entry, starting from baseline and updating to the variant after resolution. - `data-ctfl-baseline-id` is preserved for optimization-backed entries. - Loading/fallback states intentionally avoid tracking attributes until resolved, preserving React behavior. - The Web Components bundle-size budget increase reflects the now-budgeted reachable runtime chunks, including the display-contents view-tracking support from `main`. ## Validation Passed: - `pnpm --filter @contentful/optimization-web typecheck` - `pnpm --filter @contentful/optimization-web test:unit` — 276 passed - `pnpm --filter @contentful/optimization-web build` - `pnpm --filter @contentful/optimization-web size:check` - `pnpm --filter @contentful/optimization-react-web typecheck` - `pnpm --filter @contentful/optimization-react-web test:unit` — 93 passed - `pnpm --filter @contentful/optimization-react-web build` - `pnpm --filter @contentful/optimization-react-web size:check` - `pnpm lint` - `pnpm build:pkgs` - `pnpm implementation:run -- web-sdk typecheck` - `pnpm implementation:run -- web-sdk build` - `pnpm implementation:run -- web-sdk implementation:test:e2e:run` — 99 passed - `pnpm implementation:run -- react-web-sdk typecheck` - `pnpm implementation:run -- react-web-sdk build` - `pnpm implementation:run -- react-web-sdk implementation:test:e2e:run` — 84 passed - `pnpm exec eslint implementations/web-sdk implementations/react-web-sdk` - `pnpm size:check` - `git diff --check` [[NT-3484](https://contentful.atlassian.net/browse/NT-3484)] --- AGENTS.md | 14 + 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 +- .../web-sdk/e2e/live-updates.spec.ts | 133 +++++ .../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 + lib/build-tools/README.md | 4 + lib/build-tools/src/bundleSize.test.ts | 61 ++ lib/build-tools/src/bundleSize.ts | 113 +++- 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 | 15 + .../web/frameworks/react-web-sdk/package.json | 4 +- .../optimized-entry/OptimizedEntry.test.tsx | 1 + .../src/optimized-entry/OptimizedEntry.tsx | 93 ++- .../optimized-entry/optimizedEntryUtils.ts | 134 +---- .../src/optimized-entry/useOptimizedEntry.ts | 157 ++--- .../src/provider/OptimizationProvider.tsx | 94 +-- 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 +- .../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 | 55 +- 50 files changed, 4557 insertions(+), 803 deletions(-) create mode 100644 implementations/web-sdk/e2e/events-consent-gating.spec.ts create mode 100644 implementations/web-sdk/e2e/live-updates.spec.ts create mode 100644 implementations/web-sdk/e2e/offline-queue-recovery.spec.ts create mode 100644 implementations/web-sdk/e2e/web-components-lifecycle.spec.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/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/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/e2e/live-updates.spec.ts b/implementations/web-sdk/e2e/live-updates.spec.ts new file mode 100644 index 000000000..fa1671efb --- /dev/null +++ b/implementations/web-sdk/e2e/live-updates.spec.ts @@ -0,0 +1,133 @@ +import { type Locator, type Page, expect, test } from '@playwright/test' + +async function getEntryId(locator: Locator): Promise { + const text = await locator.innerText() + return text.replace('Entry: ', '').trim() +} + +async function identify(page: Page): Promise { + await page.getByTestId('live-updates-identify-button').click() + 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() + await page.goto('/') + await page.waitForLoadState('domcontentloaded') + await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() + 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') + await expect(page.locator('ctfl-opt-preview-panel')).toHaveCount(1) + await expect(page.getByTestId('live-updates-examples')).toBeVisible() + await expect + .poll(async () => { + const text = await page.getByTestId('selected-optimizations-count').innerText() + const value = Number.parseInt(text.replace('Selected Optimizations: ', ''), 10) + return Number.isNaN(value) ? 0 : value + }) + .toBeGreaterThan(0) + }) + + 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')) + + await identify(page) + + await expect(page.getByTestId('entry-id-live-default')).toHaveText( + `Entry: ${initialDefaultEntryId}`, + ) + }) + + test('global live updates ON updates default component while locked component stays fixed', async ({ + page, + }) => { + 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')) + + await identify(page) + + await expect + .poll(async () => await getEntryId(page.getByTestId('entry-id-live-default'))) + .not.toBe(initialDefaultEntryId) + await expect(page.getByTestId('entry-id-live-locked')).toHaveText( + `Entry: ${initialLockedEntryId}`, + ) + }) + + 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')) + + await identify(page) + + await expect + .poll(async () => await getEntryId(page.getByTestId('entry-id-live-enabled'))) + .not.toBe(initialLiveEntryId) + }) + + test('preview-panel override enables updates for locked components', async ({ page }) => { + const initialLockedEntryId = await getEntryId(page.getByTestId('entry-id-live-locked')) + + await getPreviewPanelToggle(page).click() + await expect(page.getByTestId('preview-panel-status')).toHaveText('Open') + + await identify(page) + + await expect + .poll(async () => await getEntryId(page.getByTestId('entry-id-live-locked'))) + .not.toBe(initialLockedEntryId) + }) + + 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 getPreviewPanelToggle(page).click() + await expect(page.getByTestId('preview-panel-status')).toHaveText('Open') + await getPreviewPanelToggle(page).click() + await expect(page.getByTestId('preview-panel-status')).toHaveText('Closed') + }) + + 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 expect(page.getByTestId('identified-status')).toHaveText('Yes') + await page.getByTestId('live-updates-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() + + 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/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

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