diff --git a/AGENTS.md b/AGENTS.md index 937ea293..f57ae01c 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 76fc1ff0..bf1197f3 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 7bd0f6f5..d5f83b50 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 59496cf9..d2e88921 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 a5f3efc2..2d5a86e4 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 f3ffec17..c6992b83 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 e49de6b3..d61004a0 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 00000000..a026a567 --- /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 64780b23..428c3f2d 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 00000000..fa1671ef --- /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 00000000..f8317a81 --- /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/public/index.html b/implementations/web-sdk/public/index.html index c047de18..4fd96627 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

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