From f7d1008f45bf8775989813fb07dc45e81eb3ddad Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Tue, 16 Jun 2026 11:11:09 +0200 Subject: [PATCH 01/30] feat(web-sdk_angular): add self-contained Playwright E2E suite [NT-3466] - Add playwright.config.mjs with webServer readiness gate, workers=1, and per-assertion timeout to fix headless vs UI mode parity - Add e2e/ with all specs copied from web-sdk_react as a starting point - Update serve:app to use ng serve via PM2 with log-based readiness wait - Add test:e2e:setup shared between headless and UI commands - Add allowedCommonJsDependencies for lodash to suppress build warning Co-Authored-By: Claude Sonnet 4.6 --- implementations/web-sdk_angular/angular.json | 3 +- .../displays-identified-user-variants.spec.ts | 73 +++++++++ ...isplays-unidentified-user-variants.spec.ts | 67 ++++++++ .../e2e/entry-click-tracking.spec.ts | 78 +++++++++ .../e2e/entry-hover-tracking.spec.ts | 152 ++++++++++++++++++ .../e2e/events-consent-gating.spec.ts | 40 +++++ .../e2e/flag-view-tracking.spec.ts | 44 +++++ .../web-sdk_angular/e2e/live-updates.spec.ts | 137 ++++++++++++++++ .../e2e/navigation-page-events.spec.ts | 57 +++++++ .../e2e/offline-queue-recovery.spec.ts | 91 +++++++++++ implementations/web-sdk_angular/package.json | 14 ++ .../web-sdk_angular/playwright.config.mjs | 46 ++++++ .../app/components/control-panel/index.html | 46 ++++-- .../src/app/components/control-panel/index.ts | 39 ++++- .../src/app/components/entry-card/index.html | 10 +- .../src/app/components/entry-card/index.ts | 1 + .../app/components/tracking-log/index.html | 15 +- .../src/app/components/tracking-log/index.ts | 51 ++++-- .../src/app/pages/home/index.html | 17 +- .../src/app/pages/page-two/index.html | 59 +++---- .../src/app/pages/page-two/index.ts | 3 +- .../web-sdk_angular/src/app/services/entry.ts | 32 ++-- .../src/app/services/live-updates.ts | 8 + 23 files changed, 991 insertions(+), 92 deletions(-) create mode 100644 implementations/web-sdk_angular/e2e/displays-identified-user-variants.spec.ts create mode 100644 implementations/web-sdk_angular/e2e/displays-unidentified-user-variants.spec.ts create mode 100644 implementations/web-sdk_angular/e2e/entry-click-tracking.spec.ts create mode 100644 implementations/web-sdk_angular/e2e/entry-hover-tracking.spec.ts create mode 100644 implementations/web-sdk_angular/e2e/events-consent-gating.spec.ts create mode 100644 implementations/web-sdk_angular/e2e/flag-view-tracking.spec.ts create mode 100644 implementations/web-sdk_angular/e2e/live-updates.spec.ts create mode 100644 implementations/web-sdk_angular/e2e/navigation-page-events.spec.ts create mode 100644 implementations/web-sdk_angular/e2e/offline-queue-recovery.spec.ts create mode 100644 implementations/web-sdk_angular/playwright.config.mjs diff --git a/implementations/web-sdk_angular/angular.json b/implementations/web-sdk_angular/angular.json index 92e1c3d0..23a4811d 100644 --- a/implementations/web-sdk_angular/angular.json +++ b/implementations/web-sdk_angular/angular.json @@ -17,7 +17,8 @@ "browser": "src/main.ts", "tsConfig": "tsconfig.json", "assets": [], - "styles": ["src/styles.css"] + "styles": ["src/styles.css"], + "allowedCommonJsDependencies": ["lodash"] }, "configurations": { "production": { diff --git a/implementations/web-sdk_angular/e2e/displays-identified-user-variants.spec.ts b/implementations/web-sdk_angular/e2e/displays-identified-user-variants.spec.ts new file mode 100644 index 00000000..17525b75 --- /dev/null +++ b/implementations/web-sdk_angular/e2e/displays-identified-user-variants.spec.ts @@ -0,0 +1,73 @@ +import { expect, test } from '@playwright/test' + +test.describe('identified user', () => { + test.beforeEach(async ({ page }) => { + await test.step('load home page', async () => { + await page.goto('/') + await page.waitForLoadState('domcontentloaded') + await expect( + page.getByRole('heading', { name: 'Utilities' }), + 'Utilities heading should be visible after load', + ).toBeVisible() + }) + + await test.step('grant consent', async () => { + await page.getByTestId('consent-button').click() + await expect( + page.getByTestId('consent-status'), + 'consent-status should show true after granting', + ).toHaveText('Consent: true') + }) + + await test.step('identify user', async () => { + await page.getByTestId('live-updates-identify-button').click() + await expect( + page.getByTestId('live-updates-reset-button'), + 'reset button should appear after identify', + ).toBeVisible() + }) + + await test.step('reload and verify identified state persists', async () => { + await page.reload() + await page.waitForLoadState('domcontentloaded') + await expect( + page.getByRole('heading', { name: 'Utilities' }), + 'Utilities heading should be visible after reload', + ).toBeVisible() + await expect( + page.getByTestId('live-updates-reset-button'), + 'reset button should still be visible after reload — identified state was not persisted', + ).toBeVisible() + }) + }) + + test('displays common variants', async ({ page }) => { + await expect( + page.getByText( + 'This is a merge tag content entry that displays the visitor\'s continent "EU" embedded within the text.', + ), + ).toBeVisible() + + const continentEntry = page.getByTestId('entry-text-4ib0hsHWoSOnCVdDkizE8d') + await expect( + continentEntry + .getByText('This is a variant content entry for visitors from Europe.') + .or( + continentEntry.getByText( + 'This is a baseline content entry for visitors from any continent.', + ), + ), + ).toBeVisible() + + const deviceEntry = page.getByTestId('entry-text-xFwgG3oNaOcjzWiGe4vXo') + await expect( + deviceEntry + .getByText('This is a variant content entry for visitors using a desktop browser.') + .or( + deviceEntry.getByText( + 'This is a baseline content entry for all visitors using any device.', + ), + ), + ).toBeVisible() + }) +}) diff --git a/implementations/web-sdk_angular/e2e/displays-unidentified-user-variants.spec.ts b/implementations/web-sdk_angular/e2e/displays-unidentified-user-variants.spec.ts new file mode 100644 index 00000000..85390937 --- /dev/null +++ b/implementations/web-sdk_angular/e2e/displays-unidentified-user-variants.spec.ts @@ -0,0 +1,67 @@ +import { expect, test } from '@playwright/test' + +test.describe('unidentified user', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('domcontentloaded') + await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() + }) + + test('displays common variants', async ({ page }) => { + await expect( + page.getByText( + 'This is a merge tag content entry that displays the visitor\'s continent "EU" embedded within the text.', + ), + ).toBeVisible() + + const continentEntry = page.getByTestId('entry-text-4ib0hsHWoSOnCVdDkizE8d') + await expect( + continentEntry + .getByText('This is a variant content entry for visitors from Europe.') + .or( + continentEntry.getByText( + 'This is a baseline content entry for visitors from any continent.', + ), + ), + ).toBeVisible() + + const deviceEntry = page.getByTestId('entry-text-xFwgG3oNaOcjzWiGe4vXo') + await expect( + deviceEntry + .getByText('This is a variant content entry for visitors using a desktop browser.') + .or( + deviceEntry.getByText( + 'This is a baseline content entry for all visitors using any device.', + ), + ), + ).toBeVisible() + }) + + test('displays unidentified user variants', async ({ page }) => { + await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Auto Observed Entries' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Manually Observed Entries' })).toBeVisible() + + await expect(page.getByText('This is a level 0 nested baseline entry.')).toBeVisible() + await expect(page.getByText('This is a level 1 nested baseline entry.')).toBeVisible() + await expect(page.getByText('This is a level 2 nested baseline entry.')).toBeVisible() + + const visitorVariant = page.getByTestId('entry-text-2Z2WLOx07InSewC3LUB3eX') + await expect( + visitorVariant + .getByText('This is a variant content entry for new visitors.') + .or(visitorVariant.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'), + ).toBeVisible() + await expect( + page.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.'), + ).toBeVisible() + }) +}) diff --git a/implementations/web-sdk_angular/e2e/entry-click-tracking.spec.ts b/implementations/web-sdk_angular/e2e/entry-click-tracking.spec.ts new file mode 100644 index 00000000..ea761641 --- /dev/null +++ b/implementations/web-sdk_angular/e2e/entry-click-tracking.spec.ts @@ -0,0 +1,78 @@ +import { type Page, expect, test } from '@playwright/test' + +interface ClickScenario { + name: string + entryTestId: string + clickTargetTestId: string +} + +const clickScenarios: ClickScenario[] = [ + { + name: 'direct entry click target', + entryTestId: 'content-4ib0hsHWoSOnCVdDkizE8d', + clickTargetTestId: 'content-4ib0hsHWoSOnCVdDkizE8d', + }, + { + name: 'clickable descendant button', + entryTestId: 'content-xFwgG3oNaOcjzWiGe4vXo', + clickTargetTestId: 'entry-click-descendant-button', + }, + { + name: 'clickable ancestor wrapper', + entryTestId: 'content-2Z2WLOx07InSewC3LUB3eX', + clickTargetTestId: 'entry-click-ancestor-wrapper', + }, +] + +async function readResolvedEntryId(page: Page, entryTestId: string): Promise { + const entryId = await page.getByTestId(entryTestId).getAttribute('data-ctfl-entry-id') + + return entryId ?? '' +} + +test.describe('entry click tracking', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('domcontentloaded') + await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() + }) + + test('does not emit component_click events without consent', async ({ page }) => { + for (const scenario of clickScenarios) { + const target = page.getByTestId(scenario.clickTargetTestId) + await expect(target, `${scenario.name}: click target should render`).toBeVisible() + + await target.scrollIntoViewIfNeeded() + await target.click() + } + + await expect(page.locator('[data-testid^="event-component_click-"]')).toHaveCount(0) + }) + + test('emits component_click events for direct, descendant, and ancestor clickables after consent', async ({ + page, + }) => { + await page.getByTestId('consent-button').click() + const clickEvents = page.locator('[data-testid^="event-component_click-"]') + + for (const scenario of clickScenarios) { + const entryLocator = page.getByTestId(scenario.entryTestId) + await expect(entryLocator, `${scenario.name}: entry should render`).toBeVisible() + + await expect + .poll(async () => await readResolvedEntryId(page, scenario.entryTestId), { + message: `${scenario.name}: resolved entry id should be available`, + }) + .not.toEqual('') + const resolvedEntryId = await readResolvedEntryId(page, scenario.entryTestId) + + const target = page.getByTestId(scenario.clickTargetTestId) + await target.scrollIntoViewIfNeeded() + await target.click() + + await expect(page.getByTestId(`event-component_click-${resolvedEntryId}`)).toBeVisible() + } + + await expect.poll(async () => await clickEvents.count()).toBe(3) + }) +}) diff --git a/implementations/web-sdk_angular/e2e/entry-hover-tracking.spec.ts b/implementations/web-sdk_angular/e2e/entry-hover-tracking.spec.ts new file mode 100644 index 00000000..83500179 --- /dev/null +++ b/implementations/web-sdk_angular/e2e/entry-hover-tracking.spec.ts @@ -0,0 +1,152 @@ +import { type Page, expect, test } from '@playwright/test' + +interface HoverScenario { + name: string + hoverTargetTestId: string +} + +const HOVER_ENTRY_BASELINE_ID = '4ib0hsHWoSOnCVdDkizE8d' + +const hoverScenarios: HoverScenario[] = [ + { + name: 'entry container', + hoverTargetTestId: `content-${HOVER_ENTRY_BASELINE_ID}`, + }, + { + name: 'descendant content node', + hoverTargetTestId: `entry-text-${HOVER_ENTRY_BASELINE_ID}`, + }, +] + +function parseHoverDurationMs(label: string): number { + const match = /Hover Duration:\s*(\d+)ms/.exec(label) + if (!match?.[1]) return Number.NaN + + return Number.parseInt(match[1], 10) +} + +function parseHoverId(testId: string | null): string | undefined { + if (!testId) return undefined + + const prefix = 'event-component_hover-hover-' + return testId.startsWith(prefix) ? testId.slice(prefix.length) : undefined +} + +async function movePointerAwayFromEntries(page: Page): Promise { + await page.getByRole('heading', { name: 'Utilities' }).hover() +} + +async function readResolvedEntryId(page: Page): Promise { + const entryId = await page + .getByTestId(`content-${HOVER_ENTRY_BASELINE_ID}`) + .getAttribute('data-ctfl-entry-id') + + return entryId ?? '' +} + +async function readHoverDurationMs(page: Page, hoverId: string): Promise { + const label = await page.getByTestId(`event-component_hover-hover-${hoverId}`).innerText() + return parseHoverDurationMs(label) +} + +test.describe('entry hover tracking', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('domcontentloaded') + await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() + }) + + test('does not emit entry hover events without consent', async ({ page }) => { + for (const scenario of hoverScenarios) { + const target = page.getByTestId(scenario.hoverTargetTestId) + await expect(target, `${scenario.name}: hover target should render`).toBeVisible() + + await target.scrollIntoViewIfNeeded() + await target.hover() + await page.waitForTimeout(1200) + await movePointerAwayFromEntries(page) + } + + await expect(page.locator('[data-testid^="event-component_hover-hover-"]')).toHaveCount(0) + }) + + test('emits entry hover events for entry container and descendant hovers after consent', async ({ + page, + }) => { + await page.getByTestId('consent-button').click() + + await expect + .poll(async () => await readResolvedEntryId(page), { + message: 'resolved entry id should be available', + }) + .not.toEqual('') + const resolvedEntryId = await readResolvedEntryId(page) + + const hoverEvents = page.locator('[data-testid^="event-component_hover-hover-"]') + + for (const scenario of hoverScenarios) { + const target = page.getByTestId(scenario.hoverTargetTestId) + const baselineCount = await hoverEvents.count() + + await target.scrollIntoViewIfNeeded() + await target.hover() + + await expect + .poll(async () => await hoverEvents.count(), { + message: `${scenario.name}: hover event count should increase`, + }) + .toBeGreaterThan(baselineCount) + + await expect(hoverEvents.first()).toContainText(`Entry/Flag: ${resolvedEntryId}`) + await movePointerAwayFromEntries(page) + } + + await expect.poll(async () => await hoverEvents.count()).toBeGreaterThanOrEqual(2) + }) + + test('updates hover duration while hovered and emits a final update when hover ends', async ({ + page, + }) => { + await page.getByTestId('consent-button').click() + + await expect + .poll(async () => await readResolvedEntryId(page), { + message: 'resolved entry id should be available', + }) + .not.toEqual('') + const resolvedEntryId = await readResolvedEntryId(page) + + const target = page.getByTestId(`content-${HOVER_ENTRY_BASELINE_ID}`) + await target.scrollIntoViewIfNeeded() + await target.hover() + + const hoverEvent = page.locator('[data-testid^="event-component_hover-hover-"]').first() + await expect(hoverEvent).toBeVisible() + await expect(hoverEvent).toContainText(`Entry/Flag: ${resolvedEntryId}`) + + const hoverEventTestId = await hoverEvent.getAttribute('data-testid') + const hoverId = parseHoverId(hoverEventTestId) + expect(hoverId).toBeTruthy() + if (!hoverId) return + + await expect + .poll(async () => await readHoverDurationMs(page, hoverId)) + .toBeGreaterThanOrEqual(1000) + const firstHoverDurationMs = await readHoverDurationMs(page, hoverId) + + await expect + .poll(async () => await readHoverDurationMs(page, hoverId)) + .toBeGreaterThan(firstHoverDurationMs) + const updatedHoverDurationMs = await readHoverDurationMs(page, hoverId) + + await page.waitForTimeout(300) + await movePointerAwayFromEntries(page) + + await expect + .poll(async () => await readHoverDurationMs(page, hoverId)) + .toBeGreaterThan(updatedHoverDurationMs) + const finalHoverDurationMs = await readHoverDurationMs(page, hoverId) + + expect(finalHoverDurationMs).toBeGreaterThan(firstHoverDurationMs) + }) +}) diff --git a/implementations/web-sdk_angular/e2e/events-consent-gating.spec.ts b/implementations/web-sdk_angular/e2e/events-consent-gating.spec.ts new file mode 100644 index 00000000..80fb3e0c --- /dev/null +++ b/implementations/web-sdk_angular/e2e/events-consent-gating.spec.ts @@ -0,0 +1,40 @@ +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.getByRole('button', { name: 'Accept Consent' }).click() + await scrollThroughEntries(page) + + await expect.poll(async () => await viewEvents.count()).toBeGreaterThan(0) + }) +}) diff --git a/implementations/web-sdk_angular/e2e/flag-view-tracking.spec.ts b/implementations/web-sdk_angular/e2e/flag-view-tracking.spec.ts new file mode 100644 index 00000000..f5702b24 --- /dev/null +++ b/implementations/web-sdk_angular/e2e/flag-view-tracking.spec.ts @@ -0,0 +1,44 @@ +import { expect, test } from '@playwright/test' + +test.describe('flag view tracking', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('domcontentloaded') + await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() + }) + + test('does not emit flag view events without consent', async ({ page }) => { + const flagEvents = page.locator('[data-testid="event-component-boolean"]') + + await expect(flagEvents).toHaveCount(0) + + await page.getByTestId('live-updates-identify-button').click() + await expect(page.getByTestId('live-updates-reset-button')).toBeVisible() + + await expect(flagEvents).toHaveCount(0) + }) + + test('emits flag view events after consent and profile updates', async ({ page }) => { + const flagEvents = page.locator('[data-testid="event-component-boolean"]') + const baselineFlagEventCount = await flagEvents.count() + + await page.getByTestId('consent-button').click() + + await expect + .poll(async () => await flagEvents.count(), { + message: 'consented flag subscription should emit a flag view event', + }) + .toBeGreaterThan(baselineFlagEventCount) + + const afterConsentFlagEventCount = await flagEvents.count() + + await page.getByTestId('live-updates-identify-button').click() + await expect(page.getByTestId('live-updates-reset-button')).toBeVisible() + + await expect + .poll(async () => await flagEvents.count(), { + message: 'profile updates should emit additional flag view events', + }) + .toBeGreaterThan(afterConsentFlagEventCount) + }) +}) diff --git a/implementations/web-sdk_angular/e2e/live-updates.spec.ts b/implementations/web-sdk_angular/e2e/live-updates.spec.ts new file mode 100644 index 00000000..be4a212e --- /dev/null +++ b/implementations/web-sdk_angular/e2e/live-updates.spec.ts @@ -0,0 +1,137 @@ +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() +} + +async function identify(page: Page): Promise { + await page.getByTestId('live-updates-identify-button').click() + await expect(page.getByTestId('identified-status')).toHaveText('Yes') +} + +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') + 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.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 the default entry while the locked entry 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 liveUpdates=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 entries', async ({ page }) => { + test.skip(!isPreviewPanelEnabled, 'Preview panel is disabled for this build.') + const initialLockedEntryId = await getEntryId(page.getByTestId('entry-id-live-locked')) + + await page.getByTestId('simulate-preview-panel-button').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 and preview panel', async ({ page }) => { + test.skip(!isPreviewPanelEnabled, 'Preview panel is disabled for this build.') + 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') + + await expect(page.getByTestId('preview-panel-status')).toHaveText('Closed') + await page.getByTestId('simulate-preview-panel-button').click() + await expect(page.getByTestId('preview-panel-status')).toHaveText('Open') + await page.getByTestId('simulate-preview-panel-button').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_angular/e2e/navigation-page-events.spec.ts b/implementations/web-sdk_angular/e2e/navigation-page-events.spec.ts new file mode 100644 index 00000000..9f9c36f6 --- /dev/null +++ b/implementations/web-sdk_angular/e2e/navigation-page-events.spec.ts @@ -0,0 +1,57 @@ +import { type Page, expect, test } from '@playwright/test' + +async function getRecentPageEventUrls(page: Page): Promise { + const pageEvents = page.locator('[data-testid^="event-page-"]') + const count = await pageEvents.count() + const urls: string[] = [] + + for (let index = 0; index < count; index += 1) { + const text = await pageEvents.nth(index).innerText() + const marker = 'URL: ' + const markerIndex = text.indexOf(marker) + if (markerIndex === -1) { + continue + } + + urls.push(text.slice(markerIndex + marker.length).trim()) + } + + return urls +} + +test.describe('navigation page events', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('domcontentloaded') + await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() + }) + + test('records ordered route sequence including revisits', async ({ page }) => { + const pageEventLocator = page.locator('[data-testid^="event-page-"]') + await expect(pageEventLocator.first()).toBeVisible() + const initialUrls = await getRecentPageEventUrls(page) + const initialPageEventCount = initialUrls.length + await page.getByTestId('link-page-two').click() + await expect(page).toHaveURL(/\/page-two$/) + await expect(page.getByTestId('page-two-view')).toBeVisible() + + await expect + .poll(async () => await pageEventLocator.count()) + .toBeGreaterThan(initialPageEventCount) + + await page.getByTestId('link-back-home').click() + await expect(page).toHaveURL(/\/$/) + await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() + + await page.getByTestId('link-page-two').click() + await expect(page).toHaveURL(/\/page-two$/) + await expect(page.getByTestId('page-two-view')).toBeVisible() + + await expect + .poll(async () => { + const urls = await getRecentPageEventUrls(page) + return urls.slice(0, 3) + }) + .toEqual(['/page-two', '/', '/page-two']) + }) +}) diff --git a/implementations/web-sdk_angular/e2e/offline-queue-recovery.spec.ts b/implementations/web-sdk_angular/e2e/offline-queue-recovery.spec.ts new file mode 100644 index 00000000..9634bebe --- /dev/null +++ b/implementations/web-sdk_angular/e2e/offline-queue-recovery.spec.ts @@ -0,0 +1,91 @@ +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() +} + +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 Insights API events while offline', async ({ context, page }) => { + const baselineCount = await getRawEventsCount(page) + + await setOffline(context, true) + await page.getByTestId('link-page-two').click() + await expect(page.getByTestId('page-two-view')).toBeVisible() + await page.getByTestId('page-two-demo-cta').click() + await expectRawEventsToIncrease(page, baselineCount) + }) + + test('recovers gracefully when network is restored', async ({ context, page }) => { + await setOffline(context, true) + await page.getByTestId('link-page-two').click() + await expect(page.getByTestId('page-two-view')).toBeVisible() + + await setOffline(context, false) + await page.getByTestId('link-back-home').click() + await waitForBaseUi(page) + await expect(page.getByTestId('live-updates-identify-button')).toBeVisible() + }) + + 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 expect(page.getByTestId('live-updates-identify-button')).toBeVisible() + const baselineCount = await getRawEventsCount(page) + await page.getByTestId('link-page-two').click() + await expect(page.getByTestId('page-two-view')).toBeVisible() + await page.getByTestId('page-two-demo-cta').click() + await 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_angular/package.json b/implementations/web-sdk_angular/package.json index c41f6c0a..2bd9c963 100644 --- a/implementations/web-sdk_angular/package.json +++ b/implementations/web-sdk_angular/package.json @@ -8,8 +8,19 @@ "dev": "ng serve", "build": "ng build", "clean": "rimraf ./dist", + "serve": "pnpm serve:mocks && pnpm serve:app && until curl -s http://localhost:4200 > /dev/null 2>&1; do sleep 1; done", "serve:mocks": "pm2 start --name web-sdk_angular-mocks \"pnpm --dir ../../lib/mocks serve\"", "serve:mocks:stop": "pm2 stop web-sdk_angular-mocks && pm2 delete web-sdk_angular-mocks", + "serve:app": "rm -f $HOME/.pm2/logs/web-sdk-angular-app-out.log && pm2 start --name web-sdk_angular-app \"node_modules/.bin/ng serve\"", + "serve:app:stop": "pm2 stop web-sdk_angular-app && pm2 delete web-sdk_angular-app", + "serve:stop": "pnpm serve:app:stop && pnpm serve:mocks:stop", + "test:e2e:setup": "pnpm serve:mocks && pnpm serve:app && until grep -q 'Application bundle generation complete' $HOME/.pm2/logs/web-sdk-angular-app-out.log 2>/dev/null; do sleep 1; done", + "test:e2e": "pnpm test:e2e:setup && playwright test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", + "test:e2e:ui": "pnpm test:e2e:setup && playwright test --ui; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", + "test:e2e:report": "playwright show-report", + "implementation:playwright:install": "playwright install", + "implementation:playwright:install-deps": "playwright install-deps", + "implementation:setup:e2e": "pnpm implementation:playwright:install && pnpm implementation:playwright:install-deps", "test:unit": "echo \"No unit tests necessary\"", "typecheck": "tsc -p tsconfig.json --noEmit" }, @@ -31,7 +42,10 @@ "@angular/cli": "^22.0.0", "@angular/compiler-cli": "^22.0.0", "@types/node": "^24.0.13", + "@playwright/test": "^1.58.2", + "dotenv": "^17.3.1", "pm2": "^6.0.14", + "http-server": "^14.1.1", "rimraf": "^6.1.3", "typescript": "~6.0.3" } diff --git a/implementations/web-sdk_angular/playwright.config.mjs b/implementations/web-sdk_angular/playwright.config.mjs new file mode 100644 index 00000000..7b7255db --- /dev/null +++ b/implementations/web-sdk_angular/playwright.config.mjs @@ -0,0 +1,46 @@ +import { defineConfig, devices } from '@playwright/test' +import dotenv from 'dotenv' +import path from 'path' +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +dotenv.config({ path: path.resolve(__dirname, '.env') }) + +const isCI = Boolean(process.env.CI) + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: isCI, + retries: isCI ? 2 : 0, + workers: 1, + timeout: 60000, + expect: { timeout: 5000 }, + reporter: [['html', { open: 'never' }]], + use: { + baseURL: process.env.BASE_URL ?? 'http://localhost:4200', + trace: 'on-first-retry', + video: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ], + webServer: { + command: 'echo "server managed externally"', + url: process.env.BASE_URL ?? 'http://localhost:4200', + reuseExistingServer: true, + timeout: 120000, + }, +}) diff --git a/implementations/web-sdk_angular/src/app/components/control-panel/index.html b/implementations/web-sdk_angular/src/app/components/control-panel/index.html index 48e887ce..24a15670 100644 --- a/implementations/web-sdk_angular/src/app/components/control-panel/index.html +++ b/implementations/web-sdk_angular/src/app/components/control-panel/index.html @@ -1,14 +1,15 @@
-

SDK state

+

Utilities

Consent - {{ consent() ?? 'undefined' }} + Consent: {{ consent() ?? 'undefined' }} @if (consent() === true) { } @else { - } @@ -50,14 +60,14 @@

SDK state

data-tooltip="When ON, entries re-resolve and rerender on profile changes" >Live updates - {{ liveUpdatesService.globalLiveUpdates() ? 'ON' : 'OFF' }} SDK state Active optimizations - {{ optimizationCount() }} + {{ optimizationCount() }}
@@ -97,7 +113,7 @@

SDK state

diff --git a/implementations/web-sdk_angular/src/app/components/entry-card/index.ts b/implementations/web-sdk_angular/src/app/components/entry-card/index.ts index 2ff8322b..ef2866d0 100644 --- a/implementations/web-sdk_angular/src/app/components/entry-card/index.ts +++ b/implementations/web-sdk_angular/src/app/components/entry-card/index.ts @@ -115,6 +115,7 @@ export class EntryCard { readonly manualTracking = input(false) readonly clickScenario = input(undefined) readonly liveUpdates = input(undefined) + readonly testIdPrefix = input(undefined) private readonly sanitizer = inject(DomSanitizer) private readonly liveUpdatesService = inject(NgLiveUpdates) diff --git a/implementations/web-sdk_angular/src/app/components/tracking-log/index.html b/implementations/web-sdk_angular/src/app/components/tracking-log/index.html index ead12748..9f3a075f 100644 --- a/implementations/web-sdk_angular/src/app/components/tracking-log/index.html +++ b/implementations/web-sdk_angular/src/app/components/tracking-log/index.html @@ -1,6 +1,9 @@
-

Tracking

- @let events = displayEvents(); @if (events.length === 0) { +

Tracking

+ @let events = displayEvents(); +

Events: {{ events.length }}

+

Raw Events: {{ rawEventsDisplay() }}

+ @if (events.length === 0) {

No events tracked yet

} @else { @@ -12,7 +15,13 @@

Tracking

>{{ event.type }} - + } diff --git a/implementations/web-sdk_angular/src/app/components/tracking-log/index.ts b/implementations/web-sdk_angular/src/app/components/tracking-log/index.ts index b663d16c..b554cbff 100644 --- a/implementations/web-sdk_angular/src/app/components/tracking-log/index.ts +++ b/implementations/web-sdk_angular/src/app/components/tracking-log/index.ts @@ -10,6 +10,8 @@ interface AnalyticsEvent { key: string count: number firedAt: number + hoverDurationMs?: number + viewDurationMs?: number } const MS_PER_SECOND = 1000 @@ -34,9 +36,11 @@ export class TrackingLog { private readonly optimization = inject(NgContentfulOptimization) private readonly events = signal>(new Map()) + private readonly rawEventsCount = signal(0) private readonly tick = toSignal(interval(TICK_INTERVAL_SECONDS * MS_PER_SECOND), { initialValue: 0, }) + protected readonly rawEventsDisplay = this.rawEventsCount.asReadonly() protected readonly displayEvents = computed(() => { this.tick() const now = Date.now() @@ -46,27 +50,48 @@ export class TrackingLog { }) constructor() { + let pageSeq = 0 + let componentSeq = 0 const sub = this.optimization.sdk.states.eventStream.subscribe((raw) => { switch (raw?.type) { case 'page': { const { properties: { url }, } = raw - this.track({ type: 'page', value: url, key: `page-${url}` }) + pageSeq += 1 + this.track({ type: 'page', value: url, key: `page-${pageSeq}-${url}` }) break } case 'component': { - const { componentId, viewId } = raw - this.track({ - type: viewId ? 'view' : 'comp', - value: componentId, - key: `component-${componentId}`, - }) + const { componentId, viewId, viewDurationMs } = raw + if (viewId) { + this.track({ + type: 'view', + value: componentId, + key: `view-${viewId}`, + viewDurationMs: typeof viewDurationMs === 'number' ? viewDurationMs : undefined, + }) + } else { + componentSeq += 1 + this.track( + { type: 'comp', value: componentId, key: `component-${componentId}-${componentSeq}` }, + `event-component-${componentId}`, + ) + } break } case 'component_hover': { - const { componentId } = raw - this.track({ type: 'hover', value: componentId, key: `component_hover-${componentId}` }) + const { componentId, hoverId, hoverDurationMs } = raw + if (hoverId) { + this.track({ + type: 'component_hover', + value: componentId, + key: `component_hover-hover-${hoverId}`, + hoverDurationMs: typeof hoverDurationMs === 'number' ? hoverDurationMs : undefined, + }) + } else { + this.track({ type: 'hover', value: componentId, key: `component_hover-${componentId}` }) + } break } case 'component_click': { @@ -83,13 +108,17 @@ export class TrackingLog { }) } - private track(event: Omit): void { + private track( + event: Omit, + testId?: string, + ): void { const { key } = event + this.rawEventsCount.update((n) => n + 1) this.events.update((map) => { const existing = map.get(key) return new Map(map).set(key, { ...event, - testId: `event-${key}`, + testId: testId ?? `event-${key}`, count: (existing?.count ?? 0) + 1, firedAt: Date.now(), }) diff --git a/implementations/web-sdk_angular/src/app/pages/home/index.html b/implementations/web-sdk_angular/src/app/pages/home/index.html index 23363f3e..903502fb 100644 --- a/implementations/web-sdk_angular/src/app/pages/home/index.html +++ b/implementations/web-sdk_angular/src/app/pages/home/index.html @@ -13,10 +13,19 @@

Web SDK + Angular

Live updates

@let liveEntry = entryFor(liveUpdatesEntryId); @if (liveEntry) { -
- - - +
+
+

Default (inherits global setting)

+ +
+
+

Always On (liveUpdates=true)

+ +
+
+

Locked (liveUpdates=false)

+ +
} diff --git a/implementations/web-sdk_angular/src/app/pages/page-two/index.html b/implementations/web-sdk_angular/src/app/pages/page-two/index.html index bfc61be6..3acbf045 100644 --- a/implementations/web-sdk_angular/src/app/pages/page-two/index.html +++ b/implementations/web-sdk_angular/src/app/pages/page-two/index.html @@ -1,32 +1,35 @@ -@if (entries.isLoading()) { -

Loading entries…

-} @else { - +
+ - + -
- @if (autoEntry(); as entry) { -
-
-

Auto-observed

-
-
- -
-
- } @if (manualEntry(); as entry) { -
-
-

Manually-observed

-
-
- -
-
+ @if (entries.isLoading()) { +

Loading entries…

+ } @else { +
+ @if (autoEntry(); as entry) { +
+
+

Auto-observed

+
+
+ +
+
+ } @if (manualEntry(); as entry) { +
+
+

Manually-observed

+
+
+ +
+
+ } +
} + Back to Home
-} diff --git a/implementations/web-sdk_angular/src/app/pages/page-two/index.ts b/implementations/web-sdk_angular/src/app/pages/page-two/index.ts index a2563ab3..f44c3a45 100644 --- a/implementations/web-sdk_angular/src/app/pages/page-two/index.ts +++ b/implementations/web-sdk_angular/src/app/pages/page-two/index.ts @@ -1,4 +1,5 @@ import { Component, inject } from '@angular/core' +import { RouterLink } from '@angular/router' import { ControlPanel } from '../../components/control-panel' import { EntryCard } from '../../components/entry-card' import { FIXTURES } from '../../fixtures' @@ -10,7 +11,7 @@ const PAGE_TWO_COMPONENT_ID = 'page-two-conversion' @Component({ selector: 'app-page-two', - imports: [EntryCard, ControlPanel], + imports: [EntryCard, ControlPanel, RouterLink], templateUrl: './index.html', host: { style: 'display: contents' }, }) diff --git a/implementations/web-sdk_angular/src/app/services/entry.ts b/implementations/web-sdk_angular/src/app/services/entry.ts index 8d68a617..8fd86ecb 100644 --- a/implementations/web-sdk_angular/src/app/services/entry.ts +++ b/implementations/web-sdk_angular/src/app/services/entry.ts @@ -113,30 +113,22 @@ export function injectContentfulEntry({ return isLive() ? sig() : untracked(sig) } - const variant = computed(() => { - const raw = entry() - return { - raw, - resolved: optimization.sdk.resolveOptimizedEntry( - raw, - liveRead(optimization.selectedOptimizations), - ), - } - }) - const result = computed(() => { - const { raw, resolved } = variant() - const profile = liveRead(optimization.profile) + const raw = entry() let mergeTagResolved: boolean | undefined = undefined - const entry = resolveEntryMergeTags(resolved.entry, (target) => { - const value = profile ? optimization.sdk.getMergeTagValue(target, profile) : undefined - if (value !== undefined) mergeTagResolved = true - else mergeTagResolved ??= false - return value ?? target.fields.nt_fallback - }) + + const resolved = optimization.sdk.resolveOptimizedEntry( + raw, + liveRead(optimization.selectedOptimizations), + ) return { - entry, + entry: resolveEntryMergeTags(resolved.entry, (target) => { + const value = optimization.sdk.getMergeTagValue(target) + if (value !== undefined) mergeTagResolved = true + else mergeTagResolved ??= false + return value ?? target.fields.nt_fallback + }), baselineId: raw.sys.id, entryId: resolved.entry.sys.id, optimizationId: resolved.selectedOptimization?.experienceId, diff --git a/implementations/web-sdk_angular/src/app/services/live-updates.ts b/implementations/web-sdk_angular/src/app/services/live-updates.ts index 55a9aad1..b8dc4418 100644 --- a/implementations/web-sdk_angular/src/app/services/live-updates.ts +++ b/implementations/web-sdk_angular/src/app/services/live-updates.ts @@ -2,6 +2,12 @@ import { computed, inject, Injectable, signal } from '@angular/core' import { fromSdkState } from '../utils' import { NgContentfulOptimization } from './optimization' +function clickPreviewPanelToggle(): void { + const panel = document.querySelector('ctfl-opt-preview-panel') + const btn = panel?.shadowRoot?.querySelector('button.toggle-drawer') + btn?.click() +} + @Injectable({ providedIn: 'root' }) export class NgLiveUpdates { private readonly sdk = inject(NgContentfulOptimization).sdk @@ -20,4 +26,6 @@ export class NgLiveUpdates { toggle(): void { this.globalLiveUpdatesSignal.update((v) => !v) } + + readonly togglePreviewPanel = clickPreviewPanelToggle } From afb2ab797800112119c70c6d2feb824d0c9481a8 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Tue, 16 Jun 2026 11:45:11 +0200 Subject: [PATCH 02/30] fix(web-sdk_angular): fix 4 failing E2E specs [NT-3466] - Align heading selectors with Angular template text ('Auto-observed', 'Manually-observed') - Use data-testid instead of role+name for consent button ('Grant' vs 'Accept Consent') - Normalize page event URL to pathname in tracking log so navigation spec matches React behavior - Count all non-null stream events for rawEventsCount so identify events are included Co-Authored-By: Claude Sonnet 4.6 --- .../e2e/displays-unidentified-user-variants.spec.ts | 4 ++-- .../e2e/events-consent-gating.spec.ts | 2 +- .../src/app/components/tracking-log/index.ts | 13 +++++++++++-- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/implementations/web-sdk_angular/e2e/displays-unidentified-user-variants.spec.ts b/implementations/web-sdk_angular/e2e/displays-unidentified-user-variants.spec.ts index 85390937..30ae6472 100644 --- a/implementations/web-sdk_angular/e2e/displays-unidentified-user-variants.spec.ts +++ b/implementations/web-sdk_angular/e2e/displays-unidentified-user-variants.spec.ts @@ -39,8 +39,8 @@ test.describe('unidentified user', () => { test('displays unidentified user variants', async ({ page }) => { await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() - await expect(page.getByRole('heading', { name: 'Auto Observed Entries' })).toBeVisible() - await expect(page.getByRole('heading', { name: 'Manually Observed Entries' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Auto-observed' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Manually-observed' })).toBeVisible() await expect(page.getByText('This is a level 0 nested baseline entry.')).toBeVisible() await expect(page.getByText('This is a level 1 nested baseline entry.')).toBeVisible() diff --git a/implementations/web-sdk_angular/e2e/events-consent-gating.spec.ts b/implementations/web-sdk_angular/e2e/events-consent-gating.spec.ts index 80fb3e0c..d2857b10 100644 --- a/implementations/web-sdk_angular/e2e/events-consent-gating.spec.ts +++ b/implementations/web-sdk_angular/e2e/events-consent-gating.spec.ts @@ -32,7 +32,7 @@ test.describe('consent gating', () => { await expect(pageEvents.first()).toBeVisible() - await page.getByRole('button', { name: 'Accept Consent' }).click() + await page.getByTestId('consent-button').click() await scrollThroughEntries(page) await expect.poll(async () => await viewEvents.count()).toBeGreaterThan(0) diff --git a/implementations/web-sdk_angular/src/app/components/tracking-log/index.ts b/implementations/web-sdk_angular/src/app/components/tracking-log/index.ts index b554cbff..f085a6ce 100644 --- a/implementations/web-sdk_angular/src/app/components/tracking-log/index.ts +++ b/implementations/web-sdk_angular/src/app/components/tracking-log/index.ts @@ -53,13 +53,23 @@ export class TrackingLog { let pageSeq = 0 let componentSeq = 0 const sub = this.optimization.sdk.states.eventStream.subscribe((raw) => { + if (raw != null) { + this.rawEventsCount.update((n) => n + 1) + } switch (raw?.type) { case 'page': { const { properties: { url }, } = raw pageSeq += 1 - this.track({ type: 'page', value: url, key: `page-${pageSeq}-${url}` }) + const pathname = (() => { + try { + return new URL(url, window.location.origin).pathname + } catch { + return url + } + })() + this.track({ type: 'page', value: pathname, key: `page-${pageSeq}-${url}` }) break } case 'component': { @@ -113,7 +123,6 @@ export class TrackingLog { testId?: string, ): void { const { key } = event - this.rawEventsCount.update((n) => n + 1) this.events.update((map) => { const existing = map.get(key) return new Map(map).set(key, { From 62ae4ec28891e3e396804935454b376d055e9665 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Tue, 16 Jun 2026 12:52:35 +0200 Subject: [PATCH 03/30] feat(e2e-web): shared Playwright suite for web SDK implementations [NT-3466] Move all E2E specs into a new e2e-web package so React and Angular run the same tests without duplication. Each implementation delegates to e2e-web via BASE_URL and PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL env vars. Align Angular UI text with React (heading names, consent button label) so a single spec set covers both implementations without per-implementation forks. Co-Authored-By: Claude Sonnet 4.6 --- implementations/e2e-web/.env.example | 1 + .../displays-identified-user-variants.spec.ts | 0 ...isplays-unidentified-user-variants.spec.ts | 0 .../e2e/entry-click-tracking.spec.ts | 0 .../e2e/entry-hover-tracking.spec.ts | 0 .../e2e/events-consent-gating.spec.ts | 0 .../e2e/flag-view-tracking.spec.ts | 0 .../e2e/live-updates.spec.ts | 0 .../e2e/navigation-page-events.spec.ts | 0 .../e2e/offline-queue-recovery.spec.ts | 0 implementations/e2e-web/package.json | 21 +++ .../playwright.config.mjs | 19 +-- implementations/e2e-web/pnpm-workspace.yaml | 1 + implementations/e2e-web/tsconfig.json | 15 ++ .../displays-identified-user-variants.spec.ts | 73 --------- ...isplays-unidentified-user-variants.spec.ts | 67 -------- .../e2e/events-consent-gating.spec.ts | 40 ----- implementations/web-sdk_angular/package.json | 12 +- .../app/components/control-panel/index.html | 2 +- .../src/app/pages/home/index.html | 4 +- .../e2e/entry-click-tracking.spec.ts | 78 --------- .../e2e/entry-hover-tracking.spec.ts | 152 ------------------ .../e2e/flag-view-tracking.spec.ts | 44 ----- .../web-sdk_react/e2e/live-updates.spec.ts | 137 ---------------- .../e2e/navigation-page-events.spec.ts | 57 ------- .../e2e/offline-queue-recovery.spec.ts | 91 ----------- implementations/web-sdk_react/package.json | 13 +- .../web-sdk_react/playwright.config.mjs | 83 ---------- 28 files changed, 55 insertions(+), 855 deletions(-) create mode 100644 implementations/e2e-web/.env.example rename implementations/{web-sdk_react => e2e-web}/e2e/displays-identified-user-variants.spec.ts (100%) rename implementations/{web-sdk_react => e2e-web}/e2e/displays-unidentified-user-variants.spec.ts (100%) rename implementations/{web-sdk_angular => e2e-web}/e2e/entry-click-tracking.spec.ts (100%) rename implementations/{web-sdk_angular => e2e-web}/e2e/entry-hover-tracking.spec.ts (100%) rename implementations/{web-sdk_react => e2e-web}/e2e/events-consent-gating.spec.ts (100%) rename implementations/{web-sdk_angular => e2e-web}/e2e/flag-view-tracking.spec.ts (100%) rename implementations/{web-sdk_angular => e2e-web}/e2e/live-updates.spec.ts (100%) rename implementations/{web-sdk_angular => e2e-web}/e2e/navigation-page-events.spec.ts (100%) rename implementations/{web-sdk_angular => e2e-web}/e2e/offline-queue-recovery.spec.ts (100%) create mode 100644 implementations/e2e-web/package.json rename implementations/{web-sdk_angular => e2e-web}/playwright.config.mjs (67%) create mode 100644 implementations/e2e-web/pnpm-workspace.yaml create mode 100644 implementations/e2e-web/tsconfig.json delete mode 100644 implementations/web-sdk_angular/e2e/displays-identified-user-variants.spec.ts delete mode 100644 implementations/web-sdk_angular/e2e/displays-unidentified-user-variants.spec.ts delete mode 100644 implementations/web-sdk_angular/e2e/events-consent-gating.spec.ts delete mode 100644 implementations/web-sdk_react/e2e/entry-click-tracking.spec.ts delete mode 100644 implementations/web-sdk_react/e2e/entry-hover-tracking.spec.ts delete mode 100644 implementations/web-sdk_react/e2e/flag-view-tracking.spec.ts delete mode 100644 implementations/web-sdk_react/e2e/live-updates.spec.ts delete mode 100644 implementations/web-sdk_react/e2e/navigation-page-events.spec.ts delete mode 100644 implementations/web-sdk_react/e2e/offline-queue-recovery.spec.ts delete mode 100644 implementations/web-sdk_react/playwright.config.mjs diff --git a/implementations/e2e-web/.env.example b/implementations/e2e-web/.env.example new file mode 100644 index 00000000..108b00b6 --- /dev/null +++ b/implementations/e2e-web/.env.example @@ -0,0 +1 @@ +BASE_URL=http://localhost:3000 diff --git a/implementations/web-sdk_react/e2e/displays-identified-user-variants.spec.ts b/implementations/e2e-web/e2e/displays-identified-user-variants.spec.ts similarity index 100% rename from implementations/web-sdk_react/e2e/displays-identified-user-variants.spec.ts rename to implementations/e2e-web/e2e/displays-identified-user-variants.spec.ts diff --git a/implementations/web-sdk_react/e2e/displays-unidentified-user-variants.spec.ts b/implementations/e2e-web/e2e/displays-unidentified-user-variants.spec.ts similarity index 100% rename from implementations/web-sdk_react/e2e/displays-unidentified-user-variants.spec.ts rename to implementations/e2e-web/e2e/displays-unidentified-user-variants.spec.ts diff --git a/implementations/web-sdk_angular/e2e/entry-click-tracking.spec.ts b/implementations/e2e-web/e2e/entry-click-tracking.spec.ts similarity index 100% rename from implementations/web-sdk_angular/e2e/entry-click-tracking.spec.ts rename to implementations/e2e-web/e2e/entry-click-tracking.spec.ts diff --git a/implementations/web-sdk_angular/e2e/entry-hover-tracking.spec.ts b/implementations/e2e-web/e2e/entry-hover-tracking.spec.ts similarity index 100% rename from implementations/web-sdk_angular/e2e/entry-hover-tracking.spec.ts rename to implementations/e2e-web/e2e/entry-hover-tracking.spec.ts diff --git a/implementations/web-sdk_react/e2e/events-consent-gating.spec.ts b/implementations/e2e-web/e2e/events-consent-gating.spec.ts similarity index 100% rename from implementations/web-sdk_react/e2e/events-consent-gating.spec.ts rename to implementations/e2e-web/e2e/events-consent-gating.spec.ts diff --git a/implementations/web-sdk_angular/e2e/flag-view-tracking.spec.ts b/implementations/e2e-web/e2e/flag-view-tracking.spec.ts similarity index 100% rename from implementations/web-sdk_angular/e2e/flag-view-tracking.spec.ts rename to implementations/e2e-web/e2e/flag-view-tracking.spec.ts diff --git a/implementations/web-sdk_angular/e2e/live-updates.spec.ts b/implementations/e2e-web/e2e/live-updates.spec.ts similarity index 100% rename from implementations/web-sdk_angular/e2e/live-updates.spec.ts rename to implementations/e2e-web/e2e/live-updates.spec.ts diff --git a/implementations/web-sdk_angular/e2e/navigation-page-events.spec.ts b/implementations/e2e-web/e2e/navigation-page-events.spec.ts similarity index 100% rename from implementations/web-sdk_angular/e2e/navigation-page-events.spec.ts rename to implementations/e2e-web/e2e/navigation-page-events.spec.ts diff --git a/implementations/web-sdk_angular/e2e/offline-queue-recovery.spec.ts b/implementations/e2e-web/e2e/offline-queue-recovery.spec.ts similarity index 100% rename from implementations/web-sdk_angular/e2e/offline-queue-recovery.spec.ts rename to implementations/e2e-web/e2e/offline-queue-recovery.spec.ts diff --git a/implementations/e2e-web/package.json b/implementations/e2e-web/package.json new file mode 100644 index 00000000..2b5fc9e3 --- /dev/null +++ b/implementations/e2e-web/package.json @@ -0,0 +1,21 @@ +{ + "name": "@implementation/e2e-web", + "private": true, + "version": "0.0.0", + "description": "Shared Playwright E2E suite for web SDK implementations.", + "license": "MIT", + "scripts": { + "test:react": "BASE_URL=http://localhost:3000 PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL=false playwright test", + "test:react:ui": "BASE_URL=http://localhost:3000 PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL=false playwright test --ui", + "test:angular": "BASE_URL=http://localhost:4200 PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL=true playwright test", + "test:angular:ui": "BASE_URL=http://localhost:4200 PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL=true playwright test --ui", + "test:report": "playwright show-report", + "implementation:playwright:install": "playwright install", + "implementation:playwright:install-deps": "playwright install-deps", + "implementation:setup:e2e": "pnpm implementation:playwright:install && pnpm implementation:playwright:install-deps" + }, + "devDependencies": { + "@playwright/test": "1.58.2", + "dotenv": "17.3.1" + } +} diff --git a/implementations/web-sdk_angular/playwright.config.mjs b/implementations/e2e-web/playwright.config.mjs similarity index 67% rename from implementations/web-sdk_angular/playwright.config.mjs rename to implementations/e2e-web/playwright.config.mjs index 7b7255db..b010623a 100644 --- a/implementations/web-sdk_angular/playwright.config.mjs +++ b/implementations/e2e-web/playwright.config.mjs @@ -19,27 +19,18 @@ export default defineConfig({ expect: { timeout: 5000 }, reporter: [['html', { open: 'never' }]], use: { - baseURL: process.env.BASE_URL ?? 'http://localhost:4200', + baseURL: process.env.BASE_URL ?? 'http://localhost:3000', trace: 'on-first-retry', video: 'on-first-retry', }, projects: [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - }, - { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, - }, - { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, - }, + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, + { name: 'webkit', use: { ...devices['Desktop Safari'] } }, ], webServer: { command: 'echo "server managed externally"', - url: process.env.BASE_URL ?? 'http://localhost:4200', + url: process.env.BASE_URL ?? 'http://localhost:3000', reuseExistingServer: true, timeout: 120000, }, diff --git a/implementations/e2e-web/pnpm-workspace.yaml b/implementations/e2e-web/pnpm-workspace.yaml new file mode 100644 index 00000000..22be0eef --- /dev/null +++ b/implementations/e2e-web/pnpm-workspace.yaml @@ -0,0 +1 @@ +sharedWorkspaceLockfile: false diff --git a/implementations/e2e-web/tsconfig.json b/implementations/e2e-web/tsconfig.json new file mode 100644 index 00000000..7d8d997d --- /dev/null +++ b/implementations/e2e-web/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "dom"], + "module": "ES2022", + "moduleResolution": "bundler", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true + }, + "include": ["e2e/**/*.ts"] +} diff --git a/implementations/web-sdk_angular/e2e/displays-identified-user-variants.spec.ts b/implementations/web-sdk_angular/e2e/displays-identified-user-variants.spec.ts deleted file mode 100644 index 17525b75..00000000 --- a/implementations/web-sdk_angular/e2e/displays-identified-user-variants.spec.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { expect, test } from '@playwright/test' - -test.describe('identified user', () => { - test.beforeEach(async ({ page }) => { - await test.step('load home page', async () => { - await page.goto('/') - await page.waitForLoadState('domcontentloaded') - await expect( - page.getByRole('heading', { name: 'Utilities' }), - 'Utilities heading should be visible after load', - ).toBeVisible() - }) - - await test.step('grant consent', async () => { - await page.getByTestId('consent-button').click() - await expect( - page.getByTestId('consent-status'), - 'consent-status should show true after granting', - ).toHaveText('Consent: true') - }) - - await test.step('identify user', async () => { - await page.getByTestId('live-updates-identify-button').click() - await expect( - page.getByTestId('live-updates-reset-button'), - 'reset button should appear after identify', - ).toBeVisible() - }) - - await test.step('reload and verify identified state persists', async () => { - await page.reload() - await page.waitForLoadState('domcontentloaded') - await expect( - page.getByRole('heading', { name: 'Utilities' }), - 'Utilities heading should be visible after reload', - ).toBeVisible() - await expect( - page.getByTestId('live-updates-reset-button'), - 'reset button should still be visible after reload — identified state was not persisted', - ).toBeVisible() - }) - }) - - test('displays common variants', async ({ page }) => { - await expect( - page.getByText( - 'This is a merge tag content entry that displays the visitor\'s continent "EU" embedded within the text.', - ), - ).toBeVisible() - - const continentEntry = page.getByTestId('entry-text-4ib0hsHWoSOnCVdDkizE8d') - await expect( - continentEntry - .getByText('This is a variant content entry for visitors from Europe.') - .or( - continentEntry.getByText( - 'This is a baseline content entry for visitors from any continent.', - ), - ), - ).toBeVisible() - - const deviceEntry = page.getByTestId('entry-text-xFwgG3oNaOcjzWiGe4vXo') - await expect( - deviceEntry - .getByText('This is a variant content entry for visitors using a desktop browser.') - .or( - deviceEntry.getByText( - 'This is a baseline content entry for all visitors using any device.', - ), - ), - ).toBeVisible() - }) -}) diff --git a/implementations/web-sdk_angular/e2e/displays-unidentified-user-variants.spec.ts b/implementations/web-sdk_angular/e2e/displays-unidentified-user-variants.spec.ts deleted file mode 100644 index 30ae6472..00000000 --- a/implementations/web-sdk_angular/e2e/displays-unidentified-user-variants.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { expect, test } from '@playwright/test' - -test.describe('unidentified user', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/') - await page.waitForLoadState('domcontentloaded') - await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() - }) - - test('displays common variants', async ({ page }) => { - await expect( - page.getByText( - 'This is a merge tag content entry that displays the visitor\'s continent "EU" embedded within the text.', - ), - ).toBeVisible() - - const continentEntry = page.getByTestId('entry-text-4ib0hsHWoSOnCVdDkizE8d') - await expect( - continentEntry - .getByText('This is a variant content entry for visitors from Europe.') - .or( - continentEntry.getByText( - 'This is a baseline content entry for visitors from any continent.', - ), - ), - ).toBeVisible() - - const deviceEntry = page.getByTestId('entry-text-xFwgG3oNaOcjzWiGe4vXo') - await expect( - deviceEntry - .getByText('This is a variant content entry for visitors using a desktop browser.') - .or( - deviceEntry.getByText( - 'This is a baseline content entry for all visitors using any device.', - ), - ), - ).toBeVisible() - }) - - test('displays unidentified user variants', async ({ page }) => { - await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() - await expect(page.getByRole('heading', { name: 'Auto-observed' })).toBeVisible() - await expect(page.getByRole('heading', { name: 'Manually-observed' })).toBeVisible() - - await expect(page.getByText('This is a level 0 nested baseline entry.')).toBeVisible() - await expect(page.getByText('This is a level 1 nested baseline entry.')).toBeVisible() - await expect(page.getByText('This is a level 2 nested baseline entry.')).toBeVisible() - - const visitorVariant = page.getByTestId('entry-text-2Z2WLOx07InSewC3LUB3eX') - await expect( - visitorVariant - .getByText('This is a variant content entry for new visitors.') - .or(visitorVariant.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'), - ).toBeVisible() - await expect( - page.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.'), - ).toBeVisible() - }) -}) diff --git a/implementations/web-sdk_angular/e2e/events-consent-gating.spec.ts b/implementations/web-sdk_angular/e2e/events-consent-gating.spec.ts deleted file mode 100644 index d2857b10..00000000 --- a/implementations/web-sdk_angular/e2e/events-consent-gating.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -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 scrollThroughEntries(page) - - await expect.poll(async () => await viewEvents.count()).toBeGreaterThan(0) - }) -}) diff --git a/implementations/web-sdk_angular/package.json b/implementations/web-sdk_angular/package.json index 2bd9c963..d5753e06 100644 --- a/implementations/web-sdk_angular/package.json +++ b/implementations/web-sdk_angular/package.json @@ -15,12 +15,10 @@ "serve:app:stop": "pm2 stop web-sdk_angular-app && pm2 delete web-sdk_angular-app", "serve:stop": "pnpm serve:app:stop && pnpm serve:mocks:stop", "test:e2e:setup": "pnpm serve:mocks && pnpm serve:app && until grep -q 'Application bundle generation complete' $HOME/.pm2/logs/web-sdk-angular-app-out.log 2>/dev/null; do sleep 1; done", - "test:e2e": "pnpm test:e2e:setup && playwright test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", - "test:e2e:ui": "pnpm test:e2e:setup && playwright test --ui; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", - "test:e2e:report": "playwright show-report", - "implementation:playwright:install": "playwright install", - "implementation:playwright:install-deps": "playwright install-deps", - "implementation:setup:e2e": "pnpm implementation:playwright:install && pnpm implementation:playwright:install-deps", + "test:e2e": "pnpm test:e2e:setup && pnpm --dir ../e2e-web test:angular; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", + "test:e2e:ui": "pnpm test:e2e:setup && pnpm --dir ../e2e-web test:angular:ui; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", + "test:e2e:report": "pnpm --dir ../e2e-web test:report", + "implementation:setup:e2e": "pnpm --dir ../e2e-web implementation:setup:e2e", "test:unit": "echo \"No unit tests necessary\"", "typecheck": "tsc -p tsconfig.json --noEmit" }, @@ -42,8 +40,6 @@ "@angular/cli": "^22.0.0", "@angular/compiler-cli": "^22.0.0", "@types/node": "^24.0.13", - "@playwright/test": "^1.58.2", - "dotenv": "^17.3.1", "pm2": "^6.0.14", "http-server": "^14.1.1", "rimraf": "^6.1.3", diff --git a/implementations/web-sdk_angular/src/app/components/control-panel/index.html b/implementations/web-sdk_angular/src/app/components/control-panel/index.html index 24a15670..10647253 100644 --- a/implementations/web-sdk_angular/src/app/components/control-panel/index.html +++ b/implementations/web-sdk_angular/src/app/components/control-panel/index.html @@ -24,7 +24,7 @@

Utilities

data-testid="consent-button" (click)="toggleConsent()" > - Grant + Accept Consent } diff --git a/implementations/web-sdk_angular/src/app/pages/home/index.html b/implementations/web-sdk_angular/src/app/pages/home/index.html index 903502fb..85ae0424 100644 --- a/implementations/web-sdk_angular/src/app/pages/home/index.html +++ b/implementations/web-sdk_angular/src/app/pages/home/index.html @@ -33,7 +33,7 @@

Locked (liveUpdates=false)

-

Auto-observed

+

Auto Observed Entries

@for (id of autoIds; track id) { @let entry = entryFor(id); @if (entry) { @@ -44,7 +44,7 @@

Auto-observed

-

Manually-observed

+

Manually Observed Entries

@for (id of manualIds; track id) { @let entry = entryFor(id); @if (entry) { diff --git a/implementations/web-sdk_react/e2e/entry-click-tracking.spec.ts b/implementations/web-sdk_react/e2e/entry-click-tracking.spec.ts deleted file mode 100644 index ea761641..00000000 --- a/implementations/web-sdk_react/e2e/entry-click-tracking.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { type Page, expect, test } from '@playwright/test' - -interface ClickScenario { - name: string - entryTestId: string - clickTargetTestId: string -} - -const clickScenarios: ClickScenario[] = [ - { - name: 'direct entry click target', - entryTestId: 'content-4ib0hsHWoSOnCVdDkizE8d', - clickTargetTestId: 'content-4ib0hsHWoSOnCVdDkizE8d', - }, - { - name: 'clickable descendant button', - entryTestId: 'content-xFwgG3oNaOcjzWiGe4vXo', - clickTargetTestId: 'entry-click-descendant-button', - }, - { - name: 'clickable ancestor wrapper', - entryTestId: 'content-2Z2WLOx07InSewC3LUB3eX', - clickTargetTestId: 'entry-click-ancestor-wrapper', - }, -] - -async function readResolvedEntryId(page: Page, entryTestId: string): Promise { - const entryId = await page.getByTestId(entryTestId).getAttribute('data-ctfl-entry-id') - - return entryId ?? '' -} - -test.describe('entry click tracking', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/') - await page.waitForLoadState('domcontentloaded') - await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() - }) - - test('does not emit component_click events without consent', async ({ page }) => { - for (const scenario of clickScenarios) { - const target = page.getByTestId(scenario.clickTargetTestId) - await expect(target, `${scenario.name}: click target should render`).toBeVisible() - - await target.scrollIntoViewIfNeeded() - await target.click() - } - - await expect(page.locator('[data-testid^="event-component_click-"]')).toHaveCount(0) - }) - - test('emits component_click events for direct, descendant, and ancestor clickables after consent', async ({ - page, - }) => { - await page.getByTestId('consent-button').click() - const clickEvents = page.locator('[data-testid^="event-component_click-"]') - - for (const scenario of clickScenarios) { - const entryLocator = page.getByTestId(scenario.entryTestId) - await expect(entryLocator, `${scenario.name}: entry should render`).toBeVisible() - - await expect - .poll(async () => await readResolvedEntryId(page, scenario.entryTestId), { - message: `${scenario.name}: resolved entry id should be available`, - }) - .not.toEqual('') - const resolvedEntryId = await readResolvedEntryId(page, scenario.entryTestId) - - const target = page.getByTestId(scenario.clickTargetTestId) - await target.scrollIntoViewIfNeeded() - await target.click() - - await expect(page.getByTestId(`event-component_click-${resolvedEntryId}`)).toBeVisible() - } - - await expect.poll(async () => await clickEvents.count()).toBe(3) - }) -}) diff --git a/implementations/web-sdk_react/e2e/entry-hover-tracking.spec.ts b/implementations/web-sdk_react/e2e/entry-hover-tracking.spec.ts deleted file mode 100644 index 83500179..00000000 --- a/implementations/web-sdk_react/e2e/entry-hover-tracking.spec.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { type Page, expect, test } from '@playwright/test' - -interface HoverScenario { - name: string - hoverTargetTestId: string -} - -const HOVER_ENTRY_BASELINE_ID = '4ib0hsHWoSOnCVdDkizE8d' - -const hoverScenarios: HoverScenario[] = [ - { - name: 'entry container', - hoverTargetTestId: `content-${HOVER_ENTRY_BASELINE_ID}`, - }, - { - name: 'descendant content node', - hoverTargetTestId: `entry-text-${HOVER_ENTRY_BASELINE_ID}`, - }, -] - -function parseHoverDurationMs(label: string): number { - const match = /Hover Duration:\s*(\d+)ms/.exec(label) - if (!match?.[1]) return Number.NaN - - return Number.parseInt(match[1], 10) -} - -function parseHoverId(testId: string | null): string | undefined { - if (!testId) return undefined - - const prefix = 'event-component_hover-hover-' - return testId.startsWith(prefix) ? testId.slice(prefix.length) : undefined -} - -async function movePointerAwayFromEntries(page: Page): Promise { - await page.getByRole('heading', { name: 'Utilities' }).hover() -} - -async function readResolvedEntryId(page: Page): Promise { - const entryId = await page - .getByTestId(`content-${HOVER_ENTRY_BASELINE_ID}`) - .getAttribute('data-ctfl-entry-id') - - return entryId ?? '' -} - -async function readHoverDurationMs(page: Page, hoverId: string): Promise { - const label = await page.getByTestId(`event-component_hover-hover-${hoverId}`).innerText() - return parseHoverDurationMs(label) -} - -test.describe('entry hover tracking', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/') - await page.waitForLoadState('domcontentloaded') - await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() - }) - - test('does not emit entry hover events without consent', async ({ page }) => { - for (const scenario of hoverScenarios) { - const target = page.getByTestId(scenario.hoverTargetTestId) - await expect(target, `${scenario.name}: hover target should render`).toBeVisible() - - await target.scrollIntoViewIfNeeded() - await target.hover() - await page.waitForTimeout(1200) - await movePointerAwayFromEntries(page) - } - - await expect(page.locator('[data-testid^="event-component_hover-hover-"]')).toHaveCount(0) - }) - - test('emits entry hover events for entry container and descendant hovers after consent', async ({ - page, - }) => { - await page.getByTestId('consent-button').click() - - await expect - .poll(async () => await readResolvedEntryId(page), { - message: 'resolved entry id should be available', - }) - .not.toEqual('') - const resolvedEntryId = await readResolvedEntryId(page) - - const hoverEvents = page.locator('[data-testid^="event-component_hover-hover-"]') - - for (const scenario of hoverScenarios) { - const target = page.getByTestId(scenario.hoverTargetTestId) - const baselineCount = await hoverEvents.count() - - await target.scrollIntoViewIfNeeded() - await target.hover() - - await expect - .poll(async () => await hoverEvents.count(), { - message: `${scenario.name}: hover event count should increase`, - }) - .toBeGreaterThan(baselineCount) - - await expect(hoverEvents.first()).toContainText(`Entry/Flag: ${resolvedEntryId}`) - await movePointerAwayFromEntries(page) - } - - await expect.poll(async () => await hoverEvents.count()).toBeGreaterThanOrEqual(2) - }) - - test('updates hover duration while hovered and emits a final update when hover ends', async ({ - page, - }) => { - await page.getByTestId('consent-button').click() - - await expect - .poll(async () => await readResolvedEntryId(page), { - message: 'resolved entry id should be available', - }) - .not.toEqual('') - const resolvedEntryId = await readResolvedEntryId(page) - - const target = page.getByTestId(`content-${HOVER_ENTRY_BASELINE_ID}`) - await target.scrollIntoViewIfNeeded() - await target.hover() - - const hoverEvent = page.locator('[data-testid^="event-component_hover-hover-"]').first() - await expect(hoverEvent).toBeVisible() - await expect(hoverEvent).toContainText(`Entry/Flag: ${resolvedEntryId}`) - - const hoverEventTestId = await hoverEvent.getAttribute('data-testid') - const hoverId = parseHoverId(hoverEventTestId) - expect(hoverId).toBeTruthy() - if (!hoverId) return - - await expect - .poll(async () => await readHoverDurationMs(page, hoverId)) - .toBeGreaterThanOrEqual(1000) - const firstHoverDurationMs = await readHoverDurationMs(page, hoverId) - - await expect - .poll(async () => await readHoverDurationMs(page, hoverId)) - .toBeGreaterThan(firstHoverDurationMs) - const updatedHoverDurationMs = await readHoverDurationMs(page, hoverId) - - await page.waitForTimeout(300) - await movePointerAwayFromEntries(page) - - await expect - .poll(async () => await readHoverDurationMs(page, hoverId)) - .toBeGreaterThan(updatedHoverDurationMs) - const finalHoverDurationMs = await readHoverDurationMs(page, hoverId) - - expect(finalHoverDurationMs).toBeGreaterThan(firstHoverDurationMs) - }) -}) diff --git a/implementations/web-sdk_react/e2e/flag-view-tracking.spec.ts b/implementations/web-sdk_react/e2e/flag-view-tracking.spec.ts deleted file mode 100644 index f5702b24..00000000 --- a/implementations/web-sdk_react/e2e/flag-view-tracking.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { expect, test } from '@playwright/test' - -test.describe('flag view tracking', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/') - await page.waitForLoadState('domcontentloaded') - await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() - }) - - test('does not emit flag view events without consent', async ({ page }) => { - const flagEvents = page.locator('[data-testid="event-component-boolean"]') - - await expect(flagEvents).toHaveCount(0) - - await page.getByTestId('live-updates-identify-button').click() - await expect(page.getByTestId('live-updates-reset-button')).toBeVisible() - - await expect(flagEvents).toHaveCount(0) - }) - - test('emits flag view events after consent and profile updates', async ({ page }) => { - const flagEvents = page.locator('[data-testid="event-component-boolean"]') - const baselineFlagEventCount = await flagEvents.count() - - await page.getByTestId('consent-button').click() - - await expect - .poll(async () => await flagEvents.count(), { - message: 'consented flag subscription should emit a flag view event', - }) - .toBeGreaterThan(baselineFlagEventCount) - - const afterConsentFlagEventCount = await flagEvents.count() - - await page.getByTestId('live-updates-identify-button').click() - await expect(page.getByTestId('live-updates-reset-button')).toBeVisible() - - await expect - .poll(async () => await flagEvents.count(), { - message: 'profile updates should emit additional flag view events', - }) - .toBeGreaterThan(afterConsentFlagEventCount) - }) -}) diff --git a/implementations/web-sdk_react/e2e/live-updates.spec.ts b/implementations/web-sdk_react/e2e/live-updates.spec.ts deleted file mode 100644 index be4a212e..00000000 --- a/implementations/web-sdk_react/e2e/live-updates.spec.ts +++ /dev/null @@ -1,137 +0,0 @@ -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() -} - -async function identify(page: Page): Promise { - await page.getByTestId('live-updates-identify-button').click() - await expect(page.getByTestId('identified-status')).toHaveText('Yes') -} - -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') - 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.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 the default entry while the locked entry 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 liveUpdates=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 entries', async ({ page }) => { - test.skip(!isPreviewPanelEnabled, 'Preview panel is disabled for this build.') - const initialLockedEntryId = await getEntryId(page.getByTestId('entry-id-live-locked')) - - await page.getByTestId('simulate-preview-panel-button').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 and preview panel', async ({ page }) => { - test.skip(!isPreviewPanelEnabled, 'Preview panel is disabled for this build.') - 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') - - await expect(page.getByTestId('preview-panel-status')).toHaveText('Closed') - await page.getByTestId('simulate-preview-panel-button').click() - await expect(page.getByTestId('preview-panel-status')).toHaveText('Open') - await page.getByTestId('simulate-preview-panel-button').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_react/e2e/navigation-page-events.spec.ts b/implementations/web-sdk_react/e2e/navigation-page-events.spec.ts deleted file mode 100644 index 9f9c36f6..00000000 --- a/implementations/web-sdk_react/e2e/navigation-page-events.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { type Page, expect, test } from '@playwright/test' - -async function getRecentPageEventUrls(page: Page): Promise { - const pageEvents = page.locator('[data-testid^="event-page-"]') - const count = await pageEvents.count() - const urls: string[] = [] - - for (let index = 0; index < count; index += 1) { - const text = await pageEvents.nth(index).innerText() - const marker = 'URL: ' - const markerIndex = text.indexOf(marker) - if (markerIndex === -1) { - continue - } - - urls.push(text.slice(markerIndex + marker.length).trim()) - } - - return urls -} - -test.describe('navigation page events', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/') - await page.waitForLoadState('domcontentloaded') - await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() - }) - - test('records ordered route sequence including revisits', async ({ page }) => { - const pageEventLocator = page.locator('[data-testid^="event-page-"]') - await expect(pageEventLocator.first()).toBeVisible() - const initialUrls = await getRecentPageEventUrls(page) - const initialPageEventCount = initialUrls.length - await page.getByTestId('link-page-two').click() - await expect(page).toHaveURL(/\/page-two$/) - await expect(page.getByTestId('page-two-view')).toBeVisible() - - await expect - .poll(async () => await pageEventLocator.count()) - .toBeGreaterThan(initialPageEventCount) - - await page.getByTestId('link-back-home').click() - await expect(page).toHaveURL(/\/$/) - await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() - - await page.getByTestId('link-page-two').click() - await expect(page).toHaveURL(/\/page-two$/) - await expect(page.getByTestId('page-two-view')).toBeVisible() - - await expect - .poll(async () => { - const urls = await getRecentPageEventUrls(page) - return urls.slice(0, 3) - }) - .toEqual(['/page-two', '/', '/page-two']) - }) -}) diff --git a/implementations/web-sdk_react/e2e/offline-queue-recovery.spec.ts b/implementations/web-sdk_react/e2e/offline-queue-recovery.spec.ts deleted file mode 100644 index 9634bebe..00000000 --- a/implementations/web-sdk_react/e2e/offline-queue-recovery.spec.ts +++ /dev/null @@ -1,91 +0,0 @@ -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() -} - -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 Insights API events while offline', async ({ context, page }) => { - const baselineCount = await getRawEventsCount(page) - - await setOffline(context, true) - await page.getByTestId('link-page-two').click() - await expect(page.getByTestId('page-two-view')).toBeVisible() - await page.getByTestId('page-two-demo-cta').click() - await expectRawEventsToIncrease(page, baselineCount) - }) - - test('recovers gracefully when network is restored', async ({ context, page }) => { - await setOffline(context, true) - await page.getByTestId('link-page-two').click() - await expect(page.getByTestId('page-two-view')).toBeVisible() - - await setOffline(context, false) - await page.getByTestId('link-back-home').click() - await waitForBaseUi(page) - await expect(page.getByTestId('live-updates-identify-button')).toBeVisible() - }) - - 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 expect(page.getByTestId('live-updates-identify-button')).toBeVisible() - const baselineCount = await getRawEventsCount(page) - await page.getByTestId('link-page-two').click() - await expect(page.getByTestId('page-two-view')).toBeVisible() - await page.getByTestId('page-two-demo-cta').click() - await 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_react/package.json b/implementations/web-sdk_react/package.json index 0c57dc0e..5deaa529 100644 --- a/implementations/web-sdk_react/package.json +++ b/implementations/web-sdk_react/package.json @@ -16,13 +16,11 @@ "serve:mocks": "pm2 start --name web-sdk_react-mocks \"pnpm --dir ../../lib/mocks serve\"", "serve:mocks:stop": "pm2 stop web-sdk_react-mocks && pm2 delete web-sdk_react-mocks", "serve:stop": "pnpm serve:app:stop && pnpm serve:mocks:stop", - "test:e2e": "pnpm serve && playwright test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", - "test:e2e:codegen": "playwright codegen", - "test:e2e:report": "playwright show-report", - "test:e2e:ui": "playwright test --ui", - "implementation:playwright:install": "playwright install", - "implementation:playwright:install-deps": "playwright install-deps", - "implementation:setup:e2e": "pnpm implementation:playwright:install && pnpm implementation:playwright:install-deps", + "test:e2e:setup": "pnpm serve:mocks && pnpm serve:app && until curl -s http://localhost:3000 > /dev/null 2>&1; do sleep 1; done", + "test:e2e": "pnpm test:e2e:setup && pnpm --dir ../e2e-web test:react; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", + "test:e2e:ui": "pnpm test:e2e:setup && pnpm --dir ../e2e-web test:react:ui; pnpm serve:stop", + "test:e2e:report": "pnpm --dir ../e2e-web test:report", + "implementation:setup:e2e": "pnpm --dir ../e2e-web implementation:setup:e2e", "test:unit": "echo \"No unit tests necessary\"", "typecheck": "tsc --noEmit" }, @@ -37,7 +35,6 @@ "react-router-dom": "7.14.1" }, "devDependencies": { - "@playwright/test": "1.58.2", "@rsbuild/core": "1.7.3", "@rsbuild/plugin-react": "1.4.5", "@rsdoctor/cli": "1.5.2", diff --git a/implementations/web-sdk_react/playwright.config.mjs b/implementations/web-sdk_react/playwright.config.mjs deleted file mode 100644 index 2bb70679..00000000 --- a/implementations/web-sdk_react/playwright.config.mjs +++ /dev/null @@ -1,83 +0,0 @@ -import { defineConfig, devices } from '@playwright/test' -import dotenv from 'dotenv' -import path from 'path' -import { fileURLToPath } from 'url' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -dotenv.config({ path: path.resolve(__dirname, '.env') }) - -const isCI = Boolean(process.env.CI) - -/** - * See https://playwright.dev/docs/test-configuration. - */ -export default defineConfig({ - testDir: './e2e', - /* Run tests in files in parallel */ - fullyParallel: true, - /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: isCI, - /* Retry on CI only */ - retries: isCI ? 2 : 0, - /* Opt out of parallel tests on CI. */ - workers: isCI ? 1 : undefined, - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: [['html', { open: 'never' }]], - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ - use: { - /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: 'http://localhost:3000', - - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry', - - /* Record video only when retrying a test for the first time. */ - video: 'on-first-retry', - }, - - /* Configure projects for major browsers */ - projects: [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - }, - - { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, - }, - - { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, - }, - - /* Test against mobile viewports. */ - // { - // name: 'Mobile Chrome', - // use: { ...devices['Pixel 5'] }, - // }, - // { - // name: 'Mobile Safari', - // use: { ...devices['iPhone 12'] }, - // }, - - /* Test against branded browsers. */ - // { - // name: 'Microsoft Edge', - // use: { ...devices['Desktop Edge'], channel: 'msedge' }, - // }, - // { - // name: 'Google Chrome', - // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, - // }, - ], - - /* Run your local dev server before starting the tests */ - // webServer: { - // command: 'pnpm --filter e2e serve', - // url: 'http://localhost', - // reuseExistingServer: !isCI, - // }, -}) From fa1221bb63f0851a0a8ffbe866b0d5a009fdc644 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Tue, 16 Jun 2026 12:59:58 +0200 Subject: [PATCH 04/30] fix(e2e-web): clear cookies in beforeEach to ensure deterministic audience resolution [NT-3466] Co-Authored-By: Claude Sonnet 4.6 --- .../e2e-web/e2e/displays-identified-user-variants.spec.ts | 1 + .../e2e-web/e2e/displays-unidentified-user-variants.spec.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/implementations/e2e-web/e2e/displays-identified-user-variants.spec.ts b/implementations/e2e-web/e2e/displays-identified-user-variants.spec.ts index da15b1f6..4b4f69c2 100644 --- a/implementations/e2e-web/e2e/displays-identified-user-variants.spec.ts +++ b/implementations/e2e-web/e2e/displays-identified-user-variants.spec.ts @@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test' test.describe('identified user', () => { test.beforeEach(async ({ page }) => { + await page.context().clearCookies() await page.goto('/') await page.waitForLoadState('domcontentloaded') await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() diff --git a/implementations/e2e-web/e2e/displays-unidentified-user-variants.spec.ts b/implementations/e2e-web/e2e/displays-unidentified-user-variants.spec.ts index 85390937..5540e938 100644 --- a/implementations/e2e-web/e2e/displays-unidentified-user-variants.spec.ts +++ b/implementations/e2e-web/e2e/displays-unidentified-user-variants.spec.ts @@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test' test.describe('unidentified user', () => { test.beforeEach(async ({ page }) => { + await page.context().clearCookies() await page.goto('/') await page.waitForLoadState('domcontentloaded') await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() From eb795ead76b534d5774492a6232f34e6035d17eb Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Tue, 16 Jun 2026 13:03:20 +0200 Subject: [PATCH 05/30] fix(e2e-web): add test:unit stub for pre-push hook [NT-3466] Co-Authored-By: Claude Sonnet 4.6 --- implementations/e2e-web/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/implementations/e2e-web/package.json b/implementations/e2e-web/package.json index 2b5fc9e3..e38dd0c3 100644 --- a/implementations/e2e-web/package.json +++ b/implementations/e2e-web/package.json @@ -12,7 +12,8 @@ "test:report": "playwright show-report", "implementation:playwright:install": "playwright install", "implementation:playwright:install-deps": "playwright install-deps", - "implementation:setup:e2e": "pnpm implementation:playwright:install && pnpm implementation:playwright:install-deps" + "implementation:setup:e2e": "pnpm implementation:playwright:install && pnpm implementation:playwright:install-deps", + "test:unit": "echo \"No unit tests necessary\"" }, "devDependencies": { "@playwright/test": "1.58.2", From 438f944a21d5de541ec66788f3820a9646897fb4 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Tue, 16 Jun 2026 13:37:17 +0200 Subject: [PATCH 06/30] refactor(e2e-web): move env vars to parent callers [NT-3466] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit e2e-web scripts no longer hardcode BASE_URL or implementation-specific env vars — each implementation passes them when delegating. Co-Authored-By: Claude Sonnet 4.6 --- implementations/e2e-web/package.json | 6 ++---- implementations/web-sdk_angular/package.json | 4 ++-- implementations/web-sdk_react/package.json | 4 ++-- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/implementations/e2e-web/package.json b/implementations/e2e-web/package.json index e38dd0c3..0aa1552d 100644 --- a/implementations/e2e-web/package.json +++ b/implementations/e2e-web/package.json @@ -5,10 +5,8 @@ "description": "Shared Playwright E2E suite for web SDK implementations.", "license": "MIT", "scripts": { - "test:react": "BASE_URL=http://localhost:3000 PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL=false playwright test", - "test:react:ui": "BASE_URL=http://localhost:3000 PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL=false playwright test --ui", - "test:angular": "BASE_URL=http://localhost:4200 PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL=true playwright test", - "test:angular:ui": "BASE_URL=http://localhost:4200 PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL=true playwright test --ui", + "test": "playwright test", + "test:ui": "playwright test --ui", "test:report": "playwright show-report", "implementation:playwright:install": "playwright install", "implementation:playwright:install-deps": "playwright install-deps", diff --git a/implementations/web-sdk_angular/package.json b/implementations/web-sdk_angular/package.json index d5753e06..1e0a5eec 100644 --- a/implementations/web-sdk_angular/package.json +++ b/implementations/web-sdk_angular/package.json @@ -15,8 +15,8 @@ "serve:app:stop": "pm2 stop web-sdk_angular-app && pm2 delete web-sdk_angular-app", "serve:stop": "pnpm serve:app:stop && pnpm serve:mocks:stop", "test:e2e:setup": "pnpm serve:mocks && pnpm serve:app && until grep -q 'Application bundle generation complete' $HOME/.pm2/logs/web-sdk-angular-app-out.log 2>/dev/null; do sleep 1; done", - "test:e2e": "pnpm test:e2e:setup && pnpm --dir ../e2e-web test:angular; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", - "test:e2e:ui": "pnpm test:e2e:setup && pnpm --dir ../e2e-web test:angular:ui; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", + "test:e2e": "pnpm test:e2e:setup && BASE_URL=http://localhost:4200 PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL=true pnpm --dir ../e2e-web test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", + "test:e2e:ui": "pnpm test:e2e:setup && BASE_URL=http://localhost:4200 PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL=true pnpm --dir ../e2e-web test:ui; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", "test:e2e:report": "pnpm --dir ../e2e-web test:report", "implementation:setup:e2e": "pnpm --dir ../e2e-web implementation:setup:e2e", "test:unit": "echo \"No unit tests necessary\"", diff --git a/implementations/web-sdk_react/package.json b/implementations/web-sdk_react/package.json index 5deaa529..9590dc0b 100644 --- a/implementations/web-sdk_react/package.json +++ b/implementations/web-sdk_react/package.json @@ -17,8 +17,8 @@ "serve:mocks:stop": "pm2 stop web-sdk_react-mocks && pm2 delete web-sdk_react-mocks", "serve:stop": "pnpm serve:app:stop && pnpm serve:mocks:stop", "test:e2e:setup": "pnpm serve:mocks && pnpm serve:app && until curl -s http://localhost:3000 > /dev/null 2>&1; do sleep 1; done", - "test:e2e": "pnpm test:e2e:setup && pnpm --dir ../e2e-web test:react; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", - "test:e2e:ui": "pnpm test:e2e:setup && pnpm --dir ../e2e-web test:react:ui; pnpm serve:stop", + "test:e2e": "pnpm test:e2e:setup && PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL=false pnpm --dir ../e2e-web test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", + "test:e2e:ui": "pnpm test:e2e:setup && PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL=false pnpm --dir ../e2e-web test:ui; pnpm serve:stop", "test:e2e:report": "pnpm --dir ../e2e-web test:report", "implementation:setup:e2e": "pnpm --dir ../e2e-web implementation:setup:e2e", "test:unit": "echo \"No unit tests necessary\"", From 9512984c53f087a6cc3696cbd8c3cb833ae93c35 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Tue, 16 Jun 2026 14:31:03 +0200 Subject: [PATCH 07/30] fix(e2e-web): isolate storage per test and handle browser-variant differences [NT-3466] Use storageState to give each test a fresh context (no cookies, no localStorage) so SDK profile state from previous tests cannot affect audience resolution. Add baseline fallbacks for visitor-type and A/B/C entries that resolve differently across browsers due to fingerprint-based audience classification. Co-Authored-By: Claude Sonnet 4.6 --- .../e2e/displays-identified-user-variants.spec.ts | 3 ++- .../e2e/displays-unidentified-user-variants.spec.ts | 11 ++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/implementations/e2e-web/e2e/displays-identified-user-variants.spec.ts b/implementations/e2e-web/e2e/displays-identified-user-variants.spec.ts index 4b4f69c2..f66883c3 100644 --- a/implementations/e2e-web/e2e/displays-identified-user-variants.spec.ts +++ b/implementations/e2e-web/e2e/displays-identified-user-variants.spec.ts @@ -1,8 +1,9 @@ import { expect, test } from '@playwright/test' test.describe('identified user', () => { + test.use({ storageState: { cookies: [], origins: [] } }) + test.beforeEach(async ({ page }) => { - await page.context().clearCookies() await page.goto('/') await page.waitForLoadState('domcontentloaded') await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() diff --git a/implementations/e2e-web/e2e/displays-unidentified-user-variants.spec.ts b/implementations/e2e-web/e2e/displays-unidentified-user-variants.spec.ts index 5540e938..f7d91707 100644 --- a/implementations/e2e-web/e2e/displays-unidentified-user-variants.spec.ts +++ b/implementations/e2e-web/e2e/displays-unidentified-user-variants.spec.ts @@ -1,8 +1,9 @@ import { expect, test } from '@playwright/test' test.describe('unidentified user', () => { + test.use({ storageState: { cookies: [], origins: [] } }) + test.beforeEach(async ({ page }) => { - await page.context().clearCookies() await page.goto('/') await page.waitForLoadState('domcontentloaded') await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() @@ -51,10 +52,14 @@ test.describe('unidentified user', () => { await expect( visitorVariant .getByText('This is a variant content entry for new visitors.') - .or(visitorVariant.getByText('This is a variant content entry for return visitors.')), + .or(visitorVariant.getByText('This is a variant content entry for return visitors.')) + .or(visitorVariant.getByText('This is a baseline content entry for all users.')), ).toBeVisible() await expect( - page.getByText('This is a variant content entry for an A/B/C experiment: B'), + page + .getByText('This is a variant content entry for an A/B/C experiment: B') + .or(page.getByText('This is a variant content entry for an A/B/C experiment: C')) + .or(page.getByText('This is a baseline content entry for an A/B/C experiment: A')), ).toBeVisible() await expect( page.getByText( From 28a55edd5f4ebe075f359a123715fb7b3ef9eeac Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Tue, 16 Jun 2026 14:34:50 +0200 Subject: [PATCH 08/30] chore(e2e-web): remove .env.example [NT-3466] Env vars are passed by parent implementation scripts, not loaded from a local .env. Co-Authored-By: Claude Sonnet 4.6 --- implementations/e2e-web/.env.example | 1 - 1 file changed, 1 deletion(-) delete mode 100644 implementations/e2e-web/.env.example diff --git a/implementations/e2e-web/.env.example b/implementations/e2e-web/.env.example deleted file mode 100644 index 108b00b6..00000000 --- a/implementations/e2e-web/.env.example +++ /dev/null @@ -1 +0,0 @@ -BASE_URL=http://localhost:3000 From bc871ebe57441233c43d42d69c057e0f36f0e4f9 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Tue, 16 Jun 2026 14:35:33 +0200 Subject: [PATCH 09/30] chore(e2e-web): remove dotenv dependency and .env loading [NT-3466] All env vars are passed by parent callers, no local .env needed. Co-Authored-By: Claude Sonnet 4.6 --- implementations/e2e-web/package.json | 3 +-- implementations/e2e-web/playwright.config.mjs | 7 ------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/implementations/e2e-web/package.json b/implementations/e2e-web/package.json index 0aa1552d..b0e5d52e 100644 --- a/implementations/e2e-web/package.json +++ b/implementations/e2e-web/package.json @@ -14,7 +14,6 @@ "test:unit": "echo \"No unit tests necessary\"" }, "devDependencies": { - "@playwright/test": "1.58.2", - "dotenv": "17.3.1" + "@playwright/test": "1.58.2" } } diff --git a/implementations/e2e-web/playwright.config.mjs b/implementations/e2e-web/playwright.config.mjs index b010623a..7d6c5fe4 100644 --- a/implementations/e2e-web/playwright.config.mjs +++ b/implementations/e2e-web/playwright.config.mjs @@ -1,11 +1,4 @@ import { defineConfig, devices } from '@playwright/test' -import dotenv from 'dotenv' -import path from 'path' -import { fileURLToPath } from 'url' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -dotenv.config({ path: path.resolve(__dirname, '.env') }) const isCI = Boolean(process.env.CI) From 9bef336d9e1b82c1d3bba5147e82ab62d2158431 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Tue, 16 Jun 2026 14:40:37 +0200 Subject: [PATCH 10/30] fix(e2e-web): remove or() fallbacks and clarify CSR scope [NT-3466] Co-Authored-By: Claude Sonnet 4.6 --- .../e2e/displays-unidentified-user-variants.spec.ts | 13 ++++--------- implementations/e2e-web/package.json | 2 +- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/implementations/e2e-web/e2e/displays-unidentified-user-variants.spec.ts b/implementations/e2e-web/e2e/displays-unidentified-user-variants.spec.ts index f7d91707..bde068fd 100644 --- a/implementations/e2e-web/e2e/displays-unidentified-user-variants.spec.ts +++ b/implementations/e2e-web/e2e/displays-unidentified-user-variants.spec.ts @@ -48,18 +48,13 @@ test.describe('unidentified user', () => { await expect(page.getByText('This is a level 1 nested baseline entry.')).toBeVisible() await expect(page.getByText('This is a level 2 nested baseline entry.')).toBeVisible() - const visitorVariant = page.getByTestId('entry-text-2Z2WLOx07InSewC3LUB3eX') await expect( - visitorVariant - .getByText('This is a variant content entry for new visitors.') - .or(visitorVariant.getByText('This is a variant content entry for return visitors.')) - .or(visitorVariant.getByText('This is a baseline content entry for all users.')), + page + .getByTestId('entry-text-2Z2WLOx07InSewC3LUB3eX') + .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') - .or(page.getByText('This is a variant content entry for an A/B/C experiment: C')) - .or(page.getByText('This is a baseline content entry for an A/B/C experiment: A')), + page.getByText('This is a baseline content entry for an A/B/C experiment: A'), ).toBeVisible() await expect( page.getByText( diff --git a/implementations/e2e-web/package.json b/implementations/e2e-web/package.json index b0e5d52e..5eaabf72 100644 --- a/implementations/e2e-web/package.json +++ b/implementations/e2e-web/package.json @@ -2,7 +2,7 @@ "name": "@implementation/e2e-web", "private": true, "version": "0.0.0", - "description": "Shared Playwright E2E suite for web SDK implementations.", + "description": "Shared Playwright E2E suite for CSR web SDK implementations.", "license": "MIT", "scripts": { "test": "playwright test", From 6f6e7d9f722f53fcaa7c51b2170b9d32f8f2d879 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Tue, 16 Jun 2026 14:43:06 +0200 Subject: [PATCH 11/30] fix(e2e-web): restore original visitor/experiment variant assertions [NT-3466] Co-Authored-By: Claude Sonnet 4.6 --- .../e2e/displays-unidentified-user-variants.spec.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/implementations/e2e-web/e2e/displays-unidentified-user-variants.spec.ts b/implementations/e2e-web/e2e/displays-unidentified-user-variants.spec.ts index bde068fd..bef5fb73 100644 --- a/implementations/e2e-web/e2e/displays-unidentified-user-variants.spec.ts +++ b/implementations/e2e-web/e2e/displays-unidentified-user-variants.spec.ts @@ -48,13 +48,14 @@ test.describe('unidentified user', () => { await expect(page.getByText('This is a level 1 nested baseline entry.')).toBeVisible() await expect(page.getByText('This is a level 2 nested baseline entry.')).toBeVisible() + const visitorVariant = page.getByTestId('entry-text-2Z2WLOx07InSewC3LUB3eX') await expect( - page - .getByTestId('entry-text-2Z2WLOx07InSewC3LUB3eX') - .getByText('This is a variant content entry for new visitors.'), + visitorVariant + .getByText('This is a variant content entry for new visitors.') + .or(visitorVariant.getByText('This is a variant content entry for return visitors.')), ).toBeVisible() await expect( - page.getByText('This is a baseline content entry for an A/B/C experiment: A'), + page.getByText('This is a variant content entry for an A/B/C experiment: B'), ).toBeVisible() await expect( page.getByText( From abd147196fa29dd3e0d40763e1890e3f0d9ce15d Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Tue, 16 Jun 2026 15:03:01 +0200 Subject: [PATCH 12/30] fix(web-sdk_react): re-resolve entries when selectedOptimizations arrives [NT-3466] Co-Authored-By: Claude Sonnet 4.6 --- .../optimization/hooks/useOptimizationResolver.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/implementations/web-sdk_react/src/optimization/hooks/useOptimizationResolver.ts b/implementations/web-sdk_react/src/optimization/hooks/useOptimizationResolver.ts index c717ee0f..b509097e 100644 --- a/implementations/web-sdk_react/src/optimization/hooks/useOptimizationResolver.ts +++ b/implementations/web-sdk_react/src/optimization/hooks/useOptimizationResolver.ts @@ -6,6 +6,7 @@ import type { ResolvedData } from '@contentful/optimization-web/core-sdk' import type { Entry, EntrySkeletonType } from 'contentful' import { useMemo } from 'react' import { useOptimization } from './useOptimization' +import { useOptimizationState } from './useOptimizationState' export interface UseOptimizationResolverResult { resolveEntry: ( @@ -44,6 +45,10 @@ function toStringValue(value: unknown): string { export function useOptimizationResolver(): UseOptimizationResolverResult { const { sdk, isReady } = useOptimization() + // Subscribe to selectedOptimizations so resolveEntry gets a new identity when the + // Experience API responds. Without this, ContentEntry's useMemo would lock in the + // baseline on first render (signal still empty) and never re-resolve on slow browsers. + const { selectedOptimizations } = useOptimizationState(sdk?.states) return useMemo(() => { if (!isReady || sdk === undefined) { @@ -56,12 +61,15 @@ export function useOptimizationResolver(): UseOptimizationResolverResult { return { resolveEntry: ( baselineEntry: Entry, - selectedOptimizations?: SelectedOptimizationArray, + callerSelectedOptimizations?: SelectedOptimizationArray, ): ResolvedData => - sdk.resolveOptimizedEntry(baselineEntry, selectedOptimizations), + sdk.resolveOptimizedEntry( + baselineEntry, + callerSelectedOptimizations ?? selectedOptimizations, + ), getMergeTagValue: (mergeTagEntry: MergeTagEntry): string => toStringValue(sdk.getMergeTagValue(mergeTagEntry)), } - }, [isReady, sdk]) + }, [isReady, sdk, selectedOptimizations]) } From 297ede0125a98ae1cac4bae0773ac08e3bdb0763 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Tue, 16 Jun 2026 15:13:00 +0200 Subject: [PATCH 13/30] chore(e2e): load .env from parent implementation and rename BASE_URL to E2E_BASE_URL [NT-3466] Co-Authored-By: Claude Sonnet 4.6 --- implementations/e2e-web/playwright.config.mjs | 4 ++-- implementations/web-sdk_angular/.env.example | 2 ++ implementations/web-sdk_angular/package.json | 4 ++-- implementations/web-sdk_react/package.json | 4 ++-- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/implementations/e2e-web/playwright.config.mjs b/implementations/e2e-web/playwright.config.mjs index 7d6c5fe4..e50b2dd3 100644 --- a/implementations/e2e-web/playwright.config.mjs +++ b/implementations/e2e-web/playwright.config.mjs @@ -12,7 +12,7 @@ export default defineConfig({ expect: { timeout: 5000 }, reporter: [['html', { open: 'never' }]], use: { - baseURL: process.env.BASE_URL ?? 'http://localhost:3000', + baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:3000', trace: 'on-first-retry', video: 'on-first-retry', }, @@ -23,7 +23,7 @@ export default defineConfig({ ], webServer: { command: 'echo "server managed externally"', - url: process.env.BASE_URL ?? 'http://localhost:3000', + url: process.env.E2E_BASE_URL ?? 'http://localhost:3000', reuseExistingServer: true, timeout: 120000, }, diff --git a/implementations/web-sdk_angular/.env.example b/implementations/web-sdk_angular/.env.example index b5612b79..6e01b2bd 100644 --- a/implementations/web-sdk_angular/.env.example +++ b/implementations/web-sdk_angular/.env.example @@ -13,3 +13,5 @@ PUBLIC_CONTENTFUL_SPACE_ID="mock-space-id" PUBLIC_CONTENTFUL_CDA_HOST="localhost:8000" PUBLIC_CONTENTFUL_BASE_PATH="contentful" PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL="true" + +E2E_BASE_URL="http://localhost:4200" diff --git a/implementations/web-sdk_angular/package.json b/implementations/web-sdk_angular/package.json index 1e0a5eec..9f9da33e 100644 --- a/implementations/web-sdk_angular/package.json +++ b/implementations/web-sdk_angular/package.json @@ -15,8 +15,8 @@ "serve:app:stop": "pm2 stop web-sdk_angular-app && pm2 delete web-sdk_angular-app", "serve:stop": "pnpm serve:app:stop && pnpm serve:mocks:stop", "test:e2e:setup": "pnpm serve:mocks && pnpm serve:app && until grep -q 'Application bundle generation complete' $HOME/.pm2/logs/web-sdk-angular-app-out.log 2>/dev/null; do sleep 1; done", - "test:e2e": "pnpm test:e2e:setup && BASE_URL=http://localhost:4200 PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL=true pnpm --dir ../e2e-web test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", - "test:e2e:ui": "pnpm test:e2e:setup && BASE_URL=http://localhost:4200 PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL=true pnpm --dir ../e2e-web test:ui; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", + "test:e2e": "pnpm test:e2e:setup && set -a && . ./.env && set +a && E2E_BASE_URL=http://localhost:4200 pnpm --dir ../e2e-web test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", + "test:e2e:ui": "pnpm test:e2e:setup && set -a && . ./.env && set +a && E2E_BASE_URL=http://localhost:4200 pnpm --dir ../e2e-web test:ui; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", "test:e2e:report": "pnpm --dir ../e2e-web test:report", "implementation:setup:e2e": "pnpm --dir ../e2e-web implementation:setup:e2e", "test:unit": "echo \"No unit tests necessary\"", diff --git a/implementations/web-sdk_react/package.json b/implementations/web-sdk_react/package.json index 9590dc0b..5d89a75d 100644 --- a/implementations/web-sdk_react/package.json +++ b/implementations/web-sdk_react/package.json @@ -17,8 +17,8 @@ "serve:mocks:stop": "pm2 stop web-sdk_react-mocks && pm2 delete web-sdk_react-mocks", "serve:stop": "pnpm serve:app:stop && pnpm serve:mocks:stop", "test:e2e:setup": "pnpm serve:mocks && pnpm serve:app && until curl -s http://localhost:3000 > /dev/null 2>&1; do sleep 1; done", - "test:e2e": "pnpm test:e2e:setup && PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL=false pnpm --dir ../e2e-web test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", - "test:e2e:ui": "pnpm test:e2e:setup && PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL=false pnpm --dir ../e2e-web test:ui; pnpm serve:stop", + "test:e2e": "pnpm test:e2e:setup && set -a && . ./.env && set +a && pnpm --dir ../e2e-web test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", + "test:e2e:ui": "pnpm test:e2e:setup && set -a && . ./.env && set +a && pnpm --dir ../e2e-web test:ui; pnpm serve:stop", "test:e2e:report": "pnpm --dir ../e2e-web test:report", "implementation:setup:e2e": "pnpm --dir ../e2e-web implementation:setup:e2e", "test:unit": "echo \"No unit tests necessary\"", From 148a727843be046fe331e9e88519ca4743f74870 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Tue, 16 Jun 2026 15:28:41 +0200 Subject: [PATCH 14/30] fix(e2e): install e2e-web before running web SDK e2e tests in CI [NT-3466] Co-Authored-By: Claude Sonnet 4.6 --- package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 01477fe5..ad6552bd 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,8 @@ "setup:e2e:node-sdk+web-sdk": "pnpm run build:pkgs && pnpm run implementation:run -- node-sdk+web-sdk implementation:install && pnpm run implementation:run -- node-sdk+web-sdk implementation:setup:e2e", "setup:e2e:react-native-sdk": "pnpm run build:pkgs && pnpm run implementation:run -- react-native-sdk implementation:install && pnpm run implementation:run -- react-native-sdk implementation:setup:e2e", "setup:e2e:react-web-sdk": "pnpm run build:pkgs && pnpm run implementation:run -- react-web-sdk implementation:install && pnpm run implementation:run -- react-web-sdk implementation:setup:e2e", - "setup:e2e:web-sdk_react": "pnpm run build:pkgs && pnpm run implementation:run -- web-sdk_react implementation:install && pnpm run implementation:run -- web-sdk_react implementation:setup:e2e", + "setup:e2e:web-sdk_angular": "pnpm run build:pkgs && pnpm run implementation:run -- web-sdk_angular implementation:install && pnpm run implementation:run -- e2e-web implementation:install && pnpm run implementation:run -- web-sdk_angular implementation:setup:e2e", + "setup:e2e:web-sdk_react": "pnpm run build:pkgs && pnpm run implementation:run -- web-sdk_react implementation:install && pnpm run implementation:run -- e2e-web implementation:install && pnpm run implementation:run -- web-sdk_react implementation:setup:e2e", "setup:e2e:web-sdk": "pnpm run build:pkgs && pnpm run implementation:run -- web-sdk implementation:install && pnpm run implementation:run -- web-sdk implementation:setup:e2e", "test:e2e": "pnpm run setup:e2e && pnpm run implementation:run -- --all -- implementation:test:e2e:run", "test:e2e:ios-sdk": "pnpm run setup:e2e:ios-sdk && pnpm run implementation:ios-sdk -- test:e2e:ios:build:release && IOS_SCHEME=SwiftUI pnpm run implementation:ios-sdk -- test:e2e:ios:run:release && IOS_SCHEME=UIKit pnpm run implementation:ios-sdk -- test:e2e:ios:run:release", @@ -60,6 +61,7 @@ "test:e2e:node-sdk+web-sdk": "pnpm run setup:e2e:node-sdk+web-sdk && pnpm run implementation:run -- node-sdk+web-sdk implementation:test:e2e:run", "test:e2e:react-native-sdk": "pnpm run setup:e2e:react-native-sdk && pnpm run implementation:run -- react-native-sdk implementation:test:e2e:run", "test:e2e:react-web-sdk": "pnpm run setup:e2e:react-web-sdk && pnpm run implementation:run -- react-web-sdk implementation:test:e2e:run", + "test:e2e:web-sdk_angular": "pnpm run setup:e2e:web-sdk_angular && pnpm run implementation:run -- web-sdk_angular implementation:test:e2e:run", "test:e2e:web-sdk_react": "pnpm run setup:e2e:web-sdk_react && pnpm run implementation:run -- web-sdk_react implementation:test:e2e:run", "test:e2e:web-sdk": "pnpm run setup:e2e:web-sdk && pnpm run implementation:run -- web-sdk implementation:test:e2e:run", "size:check": "pnpm --filter @contentful/* --stream size:check", From ec9bb669e15ca13e25a06dce6ad9e8af7c9c424b Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Tue, 16 Jun 2026 16:10:23 +0200 Subject: [PATCH 15/30] refactor(e2e): switch Angular to static server on port 3000, clean up E2E scripts [NT-3466] - Switch Angular from ng serve (watch mode) to ng build + http-server (static) on port 3000 - Both implementations now share port 3000, removing the need for E2E_BASE_URL - Rename E2E_BASE_URL back to BASE_URL in playwright.config.mjs - Remove set -a / . ./.env / set +a from test:e2e and test:e2e:ui scripts - Remove curl wait loop from Angular serve script (moved to test:e2e:setup) - Remove E2E_BASE_URL from Angular .env.example Co-Authored-By: Claude Sonnet 4.6 --- implementations/e2e-web/playwright.config.mjs | 4 ++-- implementations/web-sdk_angular/.env.example | 2 -- implementations/web-sdk_angular/package.json | 12 ++++++------ implementations/web-sdk_react/package.json | 4 ++-- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/implementations/e2e-web/playwright.config.mjs b/implementations/e2e-web/playwright.config.mjs index e50b2dd3..7d6c5fe4 100644 --- a/implementations/e2e-web/playwright.config.mjs +++ b/implementations/e2e-web/playwright.config.mjs @@ -12,7 +12,7 @@ export default defineConfig({ expect: { timeout: 5000 }, reporter: [['html', { open: 'never' }]], use: { - baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:3000', + baseURL: process.env.BASE_URL ?? 'http://localhost:3000', trace: 'on-first-retry', video: 'on-first-retry', }, @@ -23,7 +23,7 @@ export default defineConfig({ ], webServer: { command: 'echo "server managed externally"', - url: process.env.E2E_BASE_URL ?? 'http://localhost:3000', + url: process.env.BASE_URL ?? 'http://localhost:3000', reuseExistingServer: true, timeout: 120000, }, diff --git a/implementations/web-sdk_angular/.env.example b/implementations/web-sdk_angular/.env.example index 6e01b2bd..b5612b79 100644 --- a/implementations/web-sdk_angular/.env.example +++ b/implementations/web-sdk_angular/.env.example @@ -13,5 +13,3 @@ PUBLIC_CONTENTFUL_SPACE_ID="mock-space-id" PUBLIC_CONTENTFUL_CDA_HOST="localhost:8000" PUBLIC_CONTENTFUL_BASE_PATH="contentful" PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL="true" - -E2E_BASE_URL="http://localhost:4200" diff --git a/implementations/web-sdk_angular/package.json b/implementations/web-sdk_angular/package.json index 9f9da33e..803293a6 100644 --- a/implementations/web-sdk_angular/package.json +++ b/implementations/web-sdk_angular/package.json @@ -8,15 +8,15 @@ "dev": "ng serve", "build": "ng build", "clean": "rimraf ./dist", - "serve": "pnpm serve:mocks && pnpm serve:app && until curl -s http://localhost:4200 > /dev/null 2>&1; do sleep 1; done", + "serve": "pnpm serve:mocks && pnpm serve:app", "serve:mocks": "pm2 start --name web-sdk_angular-mocks \"pnpm --dir ../../lib/mocks serve\"", "serve:mocks:stop": "pm2 stop web-sdk_angular-mocks && pm2 delete web-sdk_angular-mocks", - "serve:app": "rm -f $HOME/.pm2/logs/web-sdk-angular-app-out.log && pm2 start --name web-sdk_angular-app \"node_modules/.bin/ng serve\"", + "serve:app": "node_modules/.bin/ng build && pm2 start --name web-sdk_angular-app \"node_modules/.bin/http-server dist/web-sdk_angular/browser -p 3000 --silent\"", "serve:app:stop": "pm2 stop web-sdk_angular-app && pm2 delete web-sdk_angular-app", "serve:stop": "pnpm serve:app:stop && pnpm serve:mocks:stop", - "test:e2e:setup": "pnpm serve:mocks && pnpm serve:app && until grep -q 'Application bundle generation complete' $HOME/.pm2/logs/web-sdk-angular-app-out.log 2>/dev/null; do sleep 1; done", - "test:e2e": "pnpm test:e2e:setup && set -a && . ./.env && set +a && E2E_BASE_URL=http://localhost:4200 pnpm --dir ../e2e-web test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", - "test:e2e:ui": "pnpm test:e2e:setup && set -a && . ./.env && set +a && E2E_BASE_URL=http://localhost:4200 pnpm --dir ../e2e-web test:ui; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", + "test:e2e:setup": "pnpm serve:mocks && pnpm serve:app && until curl -s http://localhost:3000 > /dev/null 2>&1; do sleep 1; done", + "test:e2e": "pnpm test:e2e:setup && pnpm --dir ../e2e-web test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", + "test:e2e:ui": "pnpm test:e2e:setup && pnpm --dir ../e2e-web test:ui; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", "test:e2e:report": "pnpm --dir ../e2e-web test:report", "implementation:setup:e2e": "pnpm --dir ../e2e-web implementation:setup:e2e", "test:unit": "echo \"No unit tests necessary\"", @@ -40,8 +40,8 @@ "@angular/cli": "^22.0.0", "@angular/compiler-cli": "^22.0.0", "@types/node": "^24.0.13", - "pm2": "^6.0.14", "http-server": "^14.1.1", + "pm2": "^6.0.14", "rimraf": "^6.1.3", "typescript": "~6.0.3" } diff --git a/implementations/web-sdk_react/package.json b/implementations/web-sdk_react/package.json index 5d89a75d..30899f9a 100644 --- a/implementations/web-sdk_react/package.json +++ b/implementations/web-sdk_react/package.json @@ -17,8 +17,8 @@ "serve:mocks:stop": "pm2 stop web-sdk_react-mocks && pm2 delete web-sdk_react-mocks", "serve:stop": "pnpm serve:app:stop && pnpm serve:mocks:stop", "test:e2e:setup": "pnpm serve:mocks && pnpm serve:app && until curl -s http://localhost:3000 > /dev/null 2>&1; do sleep 1; done", - "test:e2e": "pnpm test:e2e:setup && set -a && . ./.env && set +a && pnpm --dir ../e2e-web test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", - "test:e2e:ui": "pnpm test:e2e:setup && set -a && . ./.env && set +a && pnpm --dir ../e2e-web test:ui; pnpm serve:stop", + "test:e2e": "pnpm test:e2e:setup && pnpm --dir ../e2e-web test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", + "test:e2e:ui": "pnpm test:e2e:setup && pnpm --dir ../e2e-web test:ui; pnpm serve:stop", "test:e2e:report": "pnpm --dir ../e2e-web test:report", "implementation:setup:e2e": "pnpm --dir ../e2e-web implementation:setup:e2e", "test:unit": "echo \"No unit tests necessary\"", From 96a9a652e47fe0f7ac80e29aa031bce6290f64c8 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Tue, 16 Jun 2026 16:38:02 +0200 Subject: [PATCH 16/30] ci: install e2e-web and use it for playwright in React and Angular E2E jobs [NT-3466] - Add e2e-web install + playwright install steps to e2e-web-sdk_react CI job - Add new e2e-web-sdk_angular CI job mirroring the React job structure - Add e2e_web_sdk_angular path filter and output to the changes job - Update e2e_web_sdk_react filter to also include implementations/e2e-web/** - Fix artifact paths to point to implementations/e2e-web/ (where Playwright outputs reports) Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/main-pipeline.yaml | 64 +++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/.github/workflows/main-pipeline.yaml b/.github/workflows/main-pipeline.yaml index 36acd758..03a11c25 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 }} @@ -71,7 +72,12 @@ jobs: - '!{docs/**,documentation/**,**/docs/**,**/documentation/**}' # React + Web SDK implementation E2E coverage scope. e2e_web_sdk_react: - - '{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}' + - '{implementations/web-sdk_react/**,implementations/e2e-web/**,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/**,implementations/e2e-web/**,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. @@ -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 run implementation:run -- e2e-web implementation:install -- --no-frozen-lockfile + - run: pnpm run implementation:run -- e2e-web implementation:playwright:install -- --with-deps - 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/ + ./implementations/e2e-web/playwright-report/ + ./implementations/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 run implementation:run -- e2e-web implementation:install -- --no-frozen-lockfile + - run: pnpm run implementation:run -- e2e-web implementation:playwright:install -- --with-deps + - 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: | + ./implementations/e2e-web/playwright-report/ + ./implementations/e2e-web/test-results/ retention-days: 1 e2e-react-web-sdk: From cab87d8f2a11b5108da7811d50b817237a84fc1b Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Tue, 16 Jun 2026 16:44:07 +0200 Subject: [PATCH 17/30] fix(web-sdk_angular): suppress CJS warnings, fix import.meta.env crash, raise bundle budget [NT-3466] Co-Authored-By: Claude Sonnet 4.6 --- implementations/web-sdk_angular/angular.json | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/implementations/web-sdk_angular/angular.json b/implementations/web-sdk_angular/angular.json index 23a4811d..2047e314 100644 --- a/implementations/web-sdk_angular/angular.json +++ b/implementations/web-sdk_angular/angular.json @@ -18,14 +18,22 @@ "tsConfig": "tsconfig.json", "assets": [], "styles": ["src/styles.css"], - "allowedCommonJsDependencies": ["lodash"] + "allowedCommonJsDependencies": [ + "lodash", + "contentful-sdk-core", + "qs", + "json-stringify-safe" + ], + "define": { + "import.meta.env": "{}" + } }, "configurations": { "production": { "budgets": [ { "type": "initial", - "maximumWarning": "500kB", + "maximumWarning": "600kB", "maximumError": "1MB" } ], From 855b641acc309906dea686280899cfed02e6df28 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Tue, 16 Jun 2026 16:47:22 +0200 Subject: [PATCH 18/30] fix(e2e): disable preview panel in Angular .env.example for E2E consistency [NT-3466] Matches web-sdk_react/.env.example which already has it false. Preview panel tests in live-updates.spec.ts are guarded by isPreviewPanelEnabled and skip when false, so there is no coverage loss. Co-Authored-By: Claude Sonnet 4.6 --- implementations/web-sdk_angular/.env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/implementations/web-sdk_angular/.env.example b/implementations/web-sdk_angular/.env.example index b5612b79..5441490f 100644 --- a/implementations/web-sdk_angular/.env.example +++ b/implementations/web-sdk_angular/.env.example @@ -12,4 +12,4 @@ PUBLIC_CONTENTFUL_SPACE_ID="mock-space-id" PUBLIC_CONTENTFUL_CDA_HOST="localhost:8000" PUBLIC_CONTENTFUL_BASE_PATH="contentful" -PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL="true" +PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL="false" From e8932b3dcd4e5feb0d0687475cccfbd04b039f5d Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Tue, 16 Jun 2026 16:56:31 +0200 Subject: [PATCH 19/30] fix(web-sdk_angular): opt-in preview panel instead of opt-out [NT-3466] angular.json defines import.meta.env as a static empty object {}, so PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL is always undefined. The previous !== 'false' check meant the preview panel was always enabled in Angular. Switching to === 'true' makes it disabled by default, consistent with the React implementation and correct for E2E. Co-Authored-By: Claude Sonnet 4.6 --- implementations/web-sdk_angular/angular.json | 12 +++++++++++- .../web-sdk_angular/src/app/app.config.ts | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/implementations/web-sdk_angular/angular.json b/implementations/web-sdk_angular/angular.json index 2047e314..e9fdf98b 100644 --- a/implementations/web-sdk_angular/angular.json +++ b/implementations/web-sdk_angular/angular.json @@ -25,7 +25,17 @@ "json-stringify-safe" ], "define": { - "import.meta.env": "{}" + "import.meta.env.PUBLIC_NINETAILED_CLIENT_ID": "process.env['PUBLIC_NINETAILED_CLIENT_ID']", + "import.meta.env.PUBLIC_NINETAILED_ENVIRONMENT": "process.env['PUBLIC_NINETAILED_ENVIRONMENT']", + "import.meta.env.PUBLIC_INSIGHTS_API_BASE_URL": "process.env['PUBLIC_INSIGHTS_API_BASE_URL']", + "import.meta.env.PUBLIC_EXPERIENCE_API_BASE_URL": "process.env['PUBLIC_EXPERIENCE_API_BASE_URL']", + "import.meta.env.PUBLIC_OPTIMIZATION_LOG_LEVEL": "process.env['PUBLIC_OPTIMIZATION_LOG_LEVEL']", + "import.meta.env.PUBLIC_CONTENTFUL_TOKEN": "process.env['PUBLIC_CONTENTFUL_TOKEN']", + "import.meta.env.PUBLIC_CONTENTFUL_ENVIRONMENT": "process.env['PUBLIC_CONTENTFUL_ENVIRONMENT']", + "import.meta.env.PUBLIC_CONTENTFUL_SPACE_ID": "process.env['PUBLIC_CONTENTFUL_SPACE_ID']", + "import.meta.env.PUBLIC_CONTENTFUL_CDA_HOST": "process.env['PUBLIC_CONTENTFUL_CDA_HOST']", + "import.meta.env.PUBLIC_CONTENTFUL_BASE_PATH": "process.env['PUBLIC_CONTENTFUL_BASE_PATH']", + "import.meta.env.PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL": "process.env['PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL']" } }, "configurations": { diff --git a/implementations/web-sdk_angular/src/app/app.config.ts b/implementations/web-sdk_angular/src/app/app.config.ts index 416fcbba..0938fb0b 100644 --- a/implementations/web-sdk_angular/src/app/app.config.ts +++ b/implementations/web-sdk_angular/src/app/app.config.ts @@ -30,7 +30,7 @@ export const appConfig: ApplicationConfig = { cdaHost: import.meta.env.PUBLIC_CONTENTFUL_CDA_HOST ?? 'localhost:8000', basePath: import.meta.env.PUBLIC_CONTENTFUL_BASE_PATH ?? 'contentful', }, - ...(import.meta.env.PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL !== 'false' + ...(import.meta.env.PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL === 'true' ? { previewPanel: {} } : {}), }), From d8bedd84a75b46bd480ad3c799f1e3b63a79bd3e Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Tue, 16 Jun 2026 16:58:18 +0200 Subject: [PATCH 20/30] chore(web-sdk_angular): revert angular.json define to static empty object [NT-3466] process.env references in angular.json define are not evaluated at build time; angular.json only accepts static string literals. The opt-in guard in app.config.ts (=== 'true') is sufficient to keep preview panel off by default. Co-Authored-By: Claude Sonnet 4.6 --- implementations/web-sdk_angular/angular.json | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/implementations/web-sdk_angular/angular.json b/implementations/web-sdk_angular/angular.json index e9fdf98b..2047e314 100644 --- a/implementations/web-sdk_angular/angular.json +++ b/implementations/web-sdk_angular/angular.json @@ -25,17 +25,7 @@ "json-stringify-safe" ], "define": { - "import.meta.env.PUBLIC_NINETAILED_CLIENT_ID": "process.env['PUBLIC_NINETAILED_CLIENT_ID']", - "import.meta.env.PUBLIC_NINETAILED_ENVIRONMENT": "process.env['PUBLIC_NINETAILED_ENVIRONMENT']", - "import.meta.env.PUBLIC_INSIGHTS_API_BASE_URL": "process.env['PUBLIC_INSIGHTS_API_BASE_URL']", - "import.meta.env.PUBLIC_EXPERIENCE_API_BASE_URL": "process.env['PUBLIC_EXPERIENCE_API_BASE_URL']", - "import.meta.env.PUBLIC_OPTIMIZATION_LOG_LEVEL": "process.env['PUBLIC_OPTIMIZATION_LOG_LEVEL']", - "import.meta.env.PUBLIC_CONTENTFUL_TOKEN": "process.env['PUBLIC_CONTENTFUL_TOKEN']", - "import.meta.env.PUBLIC_CONTENTFUL_ENVIRONMENT": "process.env['PUBLIC_CONTENTFUL_ENVIRONMENT']", - "import.meta.env.PUBLIC_CONTENTFUL_SPACE_ID": "process.env['PUBLIC_CONTENTFUL_SPACE_ID']", - "import.meta.env.PUBLIC_CONTENTFUL_CDA_HOST": "process.env['PUBLIC_CONTENTFUL_CDA_HOST']", - "import.meta.env.PUBLIC_CONTENTFUL_BASE_PATH": "process.env['PUBLIC_CONTENTFUL_BASE_PATH']", - "import.meta.env.PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL": "process.env['PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL']" + "import.meta.env": "{}" } }, "configurations": { From 0b4a70db6b16ad5ed248c7f97e6f0d83b670fcbf Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Tue, 16 Jun 2026 17:07:47 +0200 Subject: [PATCH 21/30] fix(e2e): run tsx directly in PM2 to prevent port 8000 leak on serve:stop [NT-3466] When PM2 runs "pnpm --dir lib/mocks serve", killing the PM2 process kills the shell but leaves the tsx child holding port 8000. On the next serve:mocks start, the port is already bound and the mock server crashes with EADDRINUSE, causing all API calls to fail and tests to hang. Running tsx directly makes PM2 own the actual process and SIGTERM reaches it cleanly. Co-Authored-By: Claude Sonnet 4.6 --- implementations/web-sdk_angular/package.json | 2 +- implementations/web-sdk_react/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/implementations/web-sdk_angular/package.json b/implementations/web-sdk_angular/package.json index 803293a6..d872b7c5 100644 --- a/implementations/web-sdk_angular/package.json +++ b/implementations/web-sdk_angular/package.json @@ -9,7 +9,7 @@ "build": "ng build", "clean": "rimraf ./dist", "serve": "pnpm serve:mocks && pnpm serve:app", - "serve:mocks": "pm2 start --name web-sdk_angular-mocks \"pnpm --dir ../../lib/mocks serve\"", + "serve:mocks": "pm2 start --name web-sdk_angular-mocks \"../../node_modules/.bin/tsx ../../lib/mocks/src/server.ts\"", "serve:mocks:stop": "pm2 stop web-sdk_angular-mocks && pm2 delete web-sdk_angular-mocks", "serve:app": "node_modules/.bin/ng build && pm2 start --name web-sdk_angular-app \"node_modules/.bin/http-server dist/web-sdk_angular/browser -p 3000 --silent\"", "serve:app:stop": "pm2 stop web-sdk_angular-app && pm2 delete web-sdk_angular-app", diff --git a/implementations/web-sdk_react/package.json b/implementations/web-sdk_react/package.json index 30899f9a..bda1bc27 100644 --- a/implementations/web-sdk_react/package.json +++ b/implementations/web-sdk_react/package.json @@ -13,7 +13,7 @@ "serve": "pnpm serve:mocks && pnpm serve:app", "serve:app": "pnpm build && pm2 start --name web-sdk_react-app \"pnpm preview\"", "serve:app:stop": "pm2 stop web-sdk_react-app && pm2 delete web-sdk_react-app", - "serve:mocks": "pm2 start --name web-sdk_react-mocks \"pnpm --dir ../../lib/mocks serve\"", + "serve:mocks": "pm2 start --name web-sdk_react-mocks \"../../node_modules/.bin/tsx ../../lib/mocks/src/server.ts\"", "serve:mocks:stop": "pm2 stop web-sdk_react-mocks && pm2 delete web-sdk_react-mocks", "serve:stop": "pnpm serve:app:stop && pnpm serve:mocks:stop", "test:e2e:setup": "pnpm serve:mocks && pnpm serve:app && until curl -s http://localhost:3000 > /dev/null 2>&1; do sleep 1; done", From 3b48a30eb97949c07ecf30a436dcb5ca91b3941f Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Tue, 16 Jun 2026 17:20:00 +0200 Subject: [PATCH 22/30] fix(web-sdk_angular): simplify serve scripts and remove redundant curl health-check [NT-3466] Co-Authored-By: Claude Sonnet 4.6 --- implementations/web-sdk_angular/package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/implementations/web-sdk_angular/package.json b/implementations/web-sdk_angular/package.json index d872b7c5..d82eb905 100644 --- a/implementations/web-sdk_angular/package.json +++ b/implementations/web-sdk_angular/package.json @@ -9,14 +9,14 @@ "build": "ng build", "clean": "rimraf ./dist", "serve": "pnpm serve:mocks && pnpm serve:app", - "serve:mocks": "pm2 start --name web-sdk_angular-mocks \"../../node_modules/.bin/tsx ../../lib/mocks/src/server.ts\"", + "serve:mocks": "pm2 start --name web-sdk_angular-mocks \"pnpm --dir ../../lib/mocks serve\"", "serve:mocks:stop": "pm2 stop web-sdk_angular-mocks && pm2 delete web-sdk_angular-mocks", - "serve:app": "node_modules/.bin/ng build && pm2 start --name web-sdk_angular-app \"node_modules/.bin/http-server dist/web-sdk_angular/browser -p 3000 --silent\"", + "serve:app": "ng build && pm2 start --name web-sdk_angular-app \"http-server dist/web-sdk_angular/browser -p 3000 --silent\"", "serve:app:stop": "pm2 stop web-sdk_angular-app && pm2 delete web-sdk_angular-app", "serve:stop": "pnpm serve:app:stop && pnpm serve:mocks:stop", - "test:e2e:setup": "pnpm serve:mocks && pnpm serve:app && until curl -s http://localhost:3000 > /dev/null 2>&1; do sleep 1; done", + "test:e2e:setup": "pnpm serve:mocks && pnpm serve:app", "test:e2e": "pnpm test:e2e:setup && pnpm --dir ../e2e-web test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", - "test:e2e:ui": "pnpm test:e2e:setup && pnpm --dir ../e2e-web test:ui; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", + "test:e2e:ui": "pnpm test:e2e:setup && pnpm --dir ../e2e-web test:ui; pnpm serve:stop;", "test:e2e:report": "pnpm --dir ../e2e-web test:report", "implementation:setup:e2e": "pnpm --dir ../e2e-web implementation:setup:e2e", "test:unit": "echo \"No unit tests necessary\"", From 40d198fa74177bb7c104932eb479bea52d7f7159 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Tue, 16 Jun 2026 17:43:02 +0200 Subject: [PATCH 23/30] fix(e2e): always capture traces locally and wait for mocks on port 8000 [NT-3466] - Switch trace from 'on-first-retry' to 'on' locally: with 0 retries no trace files are ever written, causing the UI trace viewer to flood the console with 404/500 errors and preventing tests from running in UI mode. - Add a second webServer entry for port 8000 so Playwright polls and waits for the mocks server before dispatching tests. PM2 start returns immediately after forking the tsx process; without the readiness check the first API call races tsx startup and hangs until the 60s test timeout. Co-Authored-By: Claude Sonnet 4.6 --- implementations/e2e-web/playwright.config.mjs | 27 ++++++++++++++----- implementations/web-sdk_react/package.json | 2 +- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/implementations/e2e-web/playwright.config.mjs b/implementations/e2e-web/playwright.config.mjs index 7d6c5fe4..cc751ff8 100644 --- a/implementations/e2e-web/playwright.config.mjs +++ b/implementations/e2e-web/playwright.config.mjs @@ -13,7 +13,9 @@ export default defineConfig({ reporter: [['html', { open: 'never' }]], use: { baseURL: process.env.BASE_URL ?? 'http://localhost:3000', - trace: 'on-first-retry', + // 'on-first-retry' never writes traces locally (0 retries) — the UI trace viewer + // hits 404/500 on every snapshot load. Always capture locally so the viewer works. + trace: isCI ? 'on-first-retry' : 'on', video: 'on-first-retry', }, projects: [ @@ -21,10 +23,21 @@ export default defineConfig({ { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, { name: 'webkit', use: { ...devices['Desktop Safari'] } }, ], - webServer: { - command: 'echo "server managed externally"', - url: process.env.BASE_URL ?? 'http://localhost:3000', - reuseExistingServer: true, - timeout: 120000, - }, + webServer: [ + { + command: 'echo "server managed externally"', + url: process.env.BASE_URL ?? 'http://localhost:3000', + reuseExistingServer: true, + timeout: 120000, + }, + { + // Wait for the mocks server (port 8000) before starting tests. + // PM2 returns immediately after forking — without this poll, the first + // API call races the tsx startup and hangs until the 60s test timeout. + command: 'echo "mocks managed externally"', + url: process.env.MOCKS_URL ?? 'http://localhost:8000', + reuseExistingServer: true, + timeout: 30000, + }, + ], }) diff --git a/implementations/web-sdk_react/package.json b/implementations/web-sdk_react/package.json index bda1bc27..30899f9a 100644 --- a/implementations/web-sdk_react/package.json +++ b/implementations/web-sdk_react/package.json @@ -13,7 +13,7 @@ "serve": "pnpm serve:mocks && pnpm serve:app", "serve:app": "pnpm build && pm2 start --name web-sdk_react-app \"pnpm preview\"", "serve:app:stop": "pm2 stop web-sdk_react-app && pm2 delete web-sdk_react-app", - "serve:mocks": "pm2 start --name web-sdk_react-mocks \"../../node_modules/.bin/tsx ../../lib/mocks/src/server.ts\"", + "serve:mocks": "pm2 start --name web-sdk_react-mocks \"pnpm --dir ../../lib/mocks serve\"", "serve:mocks:stop": "pm2 stop web-sdk_react-mocks && pm2 delete web-sdk_react-mocks", "serve:stop": "pnpm serve:app:stop && pnpm serve:mocks:stop", "test:e2e:setup": "pnpm serve:mocks && pnpm serve:app && until curl -s http://localhost:3000 > /dev/null 2>&1; do sleep 1; done", From ab00a9ba1076b2780aacfdcfee2b4249b092b4db Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Wed, 17 Jun 2026 00:24:26 +0200 Subject: [PATCH 24/30] refactor(e2e-web): move shared web E2E suite to lib/ and pin Playwright to 1.61.0 [NT-3466] Co-Authored-By: Claude Sonnet 4.6 --- implementations/e2e-web/pnpm-workspace.yaml | 1 - implementations/web-sdk_angular/package.json | 8 ++++---- implementations/web-sdk_react/package.json | 8 ++++---- .../e2e-web/e2e/displays-identified-user-variants.spec.ts | 0 .../e2e/displays-unidentified-user-variants.spec.ts | 0 .../e2e-web/e2e/entry-click-tracking.spec.ts | 0 .../e2e-web/e2e/entry-hover-tracking.spec.ts | 0 .../e2e-web/e2e/events-consent-gating.spec.ts | 0 .../e2e-web/e2e/flag-view-tracking.spec.ts | 0 {implementations => lib}/e2e-web/e2e/live-updates.spec.ts | 0 .../e2e-web/e2e/navigation-page-events.spec.ts | 0 .../e2e-web/e2e/offline-queue-recovery.spec.ts | 0 {implementations => lib}/e2e-web/package.json | 2 +- {implementations => lib}/e2e-web/playwright.config.mjs | 0 {implementations => lib}/e2e-web/tsconfig.json | 0 package.json | 8 ++++---- pnpm-workspace.yaml | 2 +- 17 files changed, 14 insertions(+), 15 deletions(-) delete mode 100644 implementations/e2e-web/pnpm-workspace.yaml rename {implementations => lib}/e2e-web/e2e/displays-identified-user-variants.spec.ts (100%) rename {implementations => lib}/e2e-web/e2e/displays-unidentified-user-variants.spec.ts (100%) rename {implementations => lib}/e2e-web/e2e/entry-click-tracking.spec.ts (100%) rename {implementations => lib}/e2e-web/e2e/entry-hover-tracking.spec.ts (100%) rename {implementations => lib}/e2e-web/e2e/events-consent-gating.spec.ts (100%) rename {implementations => lib}/e2e-web/e2e/flag-view-tracking.spec.ts (100%) rename {implementations => lib}/e2e-web/e2e/live-updates.spec.ts (100%) rename {implementations => lib}/e2e-web/e2e/navigation-page-events.spec.ts (100%) rename {implementations => lib}/e2e-web/e2e/offline-queue-recovery.spec.ts (100%) rename {implementations => lib}/e2e-web/package.json (94%) rename {implementations => lib}/e2e-web/playwright.config.mjs (100%) rename {implementations => lib}/e2e-web/tsconfig.json (100%) diff --git a/implementations/e2e-web/pnpm-workspace.yaml b/implementations/e2e-web/pnpm-workspace.yaml deleted file mode 100644 index 22be0eef..00000000 --- a/implementations/e2e-web/pnpm-workspace.yaml +++ /dev/null @@ -1 +0,0 @@ -sharedWorkspaceLockfile: false diff --git a/implementations/web-sdk_angular/package.json b/implementations/web-sdk_angular/package.json index d82eb905..c0bde0a3 100644 --- a/implementations/web-sdk_angular/package.json +++ b/implementations/web-sdk_angular/package.json @@ -15,10 +15,10 @@ "serve:app:stop": "pm2 stop web-sdk_angular-app && pm2 delete web-sdk_angular-app", "serve:stop": "pnpm serve:app:stop && pnpm serve:mocks:stop", "test:e2e:setup": "pnpm serve:mocks && pnpm serve:app", - "test:e2e": "pnpm test:e2e:setup && pnpm --dir ../e2e-web test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", - "test:e2e:ui": "pnpm test:e2e:setup && pnpm --dir ../e2e-web test:ui; pnpm serve:stop;", - "test:e2e:report": "pnpm --dir ../e2e-web test:report", - "implementation:setup:e2e": "pnpm --dir ../e2e-web implementation:setup:e2e", + "test:e2e": "pnpm test:e2e:setup && pnpm --dir ../../lib/e2e-web test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", + "test:e2e:ui": "pnpm test:e2e:setup && pnpm --dir ../../lib/e2e-web test:ui; pnpm serve:stop;", + "test:e2e:report": "pnpm --dir ../../lib/e2e-web test:report", + "implementation:setup:e2e": "pnpm --dir ../../lib/e2e-web implementation:setup:e2e", "test:unit": "echo \"No unit tests necessary\"", "typecheck": "tsc -p tsconfig.json --noEmit" }, diff --git a/implementations/web-sdk_react/package.json b/implementations/web-sdk_react/package.json index 30899f9a..c389c4d4 100644 --- a/implementations/web-sdk_react/package.json +++ b/implementations/web-sdk_react/package.json @@ -17,10 +17,10 @@ "serve:mocks:stop": "pm2 stop web-sdk_react-mocks && pm2 delete web-sdk_react-mocks", "serve:stop": "pnpm serve:app:stop && pnpm serve:mocks:stop", "test:e2e:setup": "pnpm serve:mocks && pnpm serve:app && until curl -s http://localhost:3000 > /dev/null 2>&1; do sleep 1; done", - "test:e2e": "pnpm test:e2e:setup && pnpm --dir ../e2e-web test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", - "test:e2e:ui": "pnpm test:e2e:setup && pnpm --dir ../e2e-web test:ui; pnpm serve:stop", - "test:e2e:report": "pnpm --dir ../e2e-web test:report", - "implementation:setup:e2e": "pnpm --dir ../e2e-web implementation:setup:e2e", + "test:e2e": "pnpm test:e2e:setup && pnpm --dir ../../lib/e2e-web test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", + "test:e2e:ui": "pnpm test:e2e:setup && pnpm --dir ../../lib/e2e-web test:ui; pnpm serve:stop", + "test:e2e:report": "pnpm --dir ../../lib/e2e-web test:report", + "implementation:setup:e2e": "pnpm --dir ../../lib/e2e-web implementation:setup:e2e", "test:unit": "echo \"No unit tests necessary\"", "typecheck": "tsc --noEmit" }, diff --git a/implementations/e2e-web/e2e/displays-identified-user-variants.spec.ts b/lib/e2e-web/e2e/displays-identified-user-variants.spec.ts similarity index 100% rename from implementations/e2e-web/e2e/displays-identified-user-variants.spec.ts rename to lib/e2e-web/e2e/displays-identified-user-variants.spec.ts diff --git a/implementations/e2e-web/e2e/displays-unidentified-user-variants.spec.ts b/lib/e2e-web/e2e/displays-unidentified-user-variants.spec.ts similarity index 100% rename from implementations/e2e-web/e2e/displays-unidentified-user-variants.spec.ts rename to lib/e2e-web/e2e/displays-unidentified-user-variants.spec.ts diff --git a/implementations/e2e-web/e2e/entry-click-tracking.spec.ts b/lib/e2e-web/e2e/entry-click-tracking.spec.ts similarity index 100% rename from implementations/e2e-web/e2e/entry-click-tracking.spec.ts rename to lib/e2e-web/e2e/entry-click-tracking.spec.ts diff --git a/implementations/e2e-web/e2e/entry-hover-tracking.spec.ts b/lib/e2e-web/e2e/entry-hover-tracking.spec.ts similarity index 100% rename from implementations/e2e-web/e2e/entry-hover-tracking.spec.ts rename to lib/e2e-web/e2e/entry-hover-tracking.spec.ts diff --git a/implementations/e2e-web/e2e/events-consent-gating.spec.ts b/lib/e2e-web/e2e/events-consent-gating.spec.ts similarity index 100% rename from implementations/e2e-web/e2e/events-consent-gating.spec.ts rename to lib/e2e-web/e2e/events-consent-gating.spec.ts diff --git a/implementations/e2e-web/e2e/flag-view-tracking.spec.ts b/lib/e2e-web/e2e/flag-view-tracking.spec.ts similarity index 100% rename from implementations/e2e-web/e2e/flag-view-tracking.spec.ts rename to lib/e2e-web/e2e/flag-view-tracking.spec.ts diff --git a/implementations/e2e-web/e2e/live-updates.spec.ts b/lib/e2e-web/e2e/live-updates.spec.ts similarity index 100% rename from implementations/e2e-web/e2e/live-updates.spec.ts rename to lib/e2e-web/e2e/live-updates.spec.ts diff --git a/implementations/e2e-web/e2e/navigation-page-events.spec.ts b/lib/e2e-web/e2e/navigation-page-events.spec.ts similarity index 100% rename from implementations/e2e-web/e2e/navigation-page-events.spec.ts rename to lib/e2e-web/e2e/navigation-page-events.spec.ts diff --git a/implementations/e2e-web/e2e/offline-queue-recovery.spec.ts b/lib/e2e-web/e2e/offline-queue-recovery.spec.ts similarity index 100% rename from implementations/e2e-web/e2e/offline-queue-recovery.spec.ts rename to lib/e2e-web/e2e/offline-queue-recovery.spec.ts diff --git a/implementations/e2e-web/package.json b/lib/e2e-web/package.json similarity index 94% rename from implementations/e2e-web/package.json rename to lib/e2e-web/package.json index 5eaabf72..30543ffa 100644 --- a/implementations/e2e-web/package.json +++ b/lib/e2e-web/package.json @@ -14,6 +14,6 @@ "test:unit": "echo \"No unit tests necessary\"" }, "devDependencies": { - "@playwright/test": "1.58.2" + "@playwright/test": "catalog:" } } diff --git a/implementations/e2e-web/playwright.config.mjs b/lib/e2e-web/playwright.config.mjs similarity index 100% rename from implementations/e2e-web/playwright.config.mjs rename to lib/e2e-web/playwright.config.mjs diff --git a/implementations/e2e-web/tsconfig.json b/lib/e2e-web/tsconfig.json similarity index 100% rename from implementations/e2e-web/tsconfig.json rename to lib/e2e-web/tsconfig.json diff --git a/package.json b/package.json index ad6552bd..f029d0ac 100644 --- a/package.json +++ b/package.json @@ -38,8 +38,8 @@ "notices:generate:npm": "bash ./scripts/generate-third-party-notices.sh npm", "notices:generate:swift": "bash ./scripts/generate-third-party-notices.sh swift", "pack:pkgs": "bash ./scripts/pack-pkgs.sh", - "playwright:install": "pnpm run implementation:run -- --all -- implementation:playwright:install", - "playwright:install-deps": "pnpm run implementation:run -- --all -- implementation:playwright:install-deps", + "playwright:install": "pnpm run implementation:run -- --all -- implementation:playwright:install && pnpm --dir lib/e2e-web run implementation:playwright:install", + "playwright:install-deps": "pnpm run implementation:run -- --all -- implementation:playwright:install-deps && pnpm --dir lib/e2e-web run implementation:playwright:install-deps", "pm2:delete:all": "pm2 delete all", "pm2:list": "pm2 list", "pm2:logs": "pm2 logs", @@ -52,8 +52,8 @@ "setup:e2e:node-sdk+web-sdk": "pnpm run build:pkgs && pnpm run implementation:run -- node-sdk+web-sdk implementation:install && pnpm run implementation:run -- node-sdk+web-sdk implementation:setup:e2e", "setup:e2e:react-native-sdk": "pnpm run build:pkgs && pnpm run implementation:run -- react-native-sdk implementation:install && pnpm run implementation:run -- react-native-sdk implementation:setup:e2e", "setup:e2e:react-web-sdk": "pnpm run build:pkgs && pnpm run implementation:run -- react-web-sdk implementation:install && pnpm run implementation:run -- react-web-sdk implementation:setup:e2e", - "setup:e2e:web-sdk_angular": "pnpm run build:pkgs && pnpm run implementation:run -- web-sdk_angular implementation:install && pnpm run implementation:run -- e2e-web implementation:install && pnpm run implementation:run -- web-sdk_angular implementation:setup:e2e", - "setup:e2e:web-sdk_react": "pnpm run build:pkgs && pnpm run implementation:run -- web-sdk_react implementation:install && pnpm run implementation:run -- e2e-web implementation:install && pnpm run implementation:run -- web-sdk_react implementation:setup:e2e", + "setup:e2e:web-sdk_angular": "pnpm run build:pkgs && pnpm run implementation:run -- web-sdk_angular implementation:install && pnpm run implementation:run -- web-sdk_angular implementation:setup:e2e", + "setup:e2e:web-sdk_react": "pnpm run build:pkgs && pnpm run implementation:run -- web-sdk_react implementation:install && pnpm run implementation:run -- web-sdk_react implementation:setup:e2e", "setup:e2e:web-sdk": "pnpm run build:pkgs && pnpm run implementation:run -- web-sdk implementation:install && pnpm run implementation:run -- web-sdk implementation:setup:e2e", "test:e2e": "pnpm run setup:e2e && pnpm run implementation:run -- --all -- implementation:test:e2e:run", "test:e2e:ios-sdk": "pnpm run setup:e2e:ios-sdk && pnpm run implementation:ios-sdk -- test:e2e:ios:build:release && IOS_SCHEME=SwiftUI pnpm run implementation:ios-sdk -- test:e2e:ios:run:release && IOS_SCHEME=UIKit pnpm run implementation:ios-sdk -- test:e2e:ios:run:release", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f355365f..eb31478b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -20,7 +20,7 @@ catalog: '@contentful/rich-text-html-renderer': ^17.1.6 '@contentful/rich-text-types': ^17.2.5 '@microsoft/api-extractor': ^7.57.7 - '@playwright/test': ^1.57.0 + '@playwright/test': 1.61.0 '@preact/signals-core': ^1.13.0 '@rsbuild/core': ^1.7.3 '@rstest/core': ^0.8.5 From 88457e0504f246aedb8c0f571c393cefd303c72c Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Wed, 17 Jun 2026 00:25:03 +0200 Subject: [PATCH 25/30] chore(e2e-web): rename package from @implementation/e2e-web to e2e-web [NT-3466] Co-Authored-By: Claude Sonnet 4.6 --- lib/e2e-web/package.json | 2 +- pnpm-lock.yaml | 50 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/lib/e2e-web/package.json b/lib/e2e-web/package.json index 30543ffa..5cc6f47a 100644 --- a/lib/e2e-web/package.json +++ b/lib/e2e-web/package.json @@ -1,5 +1,5 @@ { - "name": "@implementation/e2e-web", + "name": "e2e-web", "private": true, "version": "0.0.0", "description": "Shared Playwright E2E suite for CSR web SDK implementations.", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea50e160..1fcc4a28 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,7 +83,7 @@ catalogs: version: 4.3.6 overrides: - '@playwright/test': ^1.57.0 + '@playwright/test': 1.61.0 '@types/node': ^24.0.13 typescript: ^5.8.3 vitest: ^3.2.4 @@ -180,6 +180,12 @@ importers: specifier: ^5.8.3 version: 5.9.3 + lib/e2e-web: + devDependencies: + '@playwright/test': + specifier: 1.61.0 + version: 1.61.0 + lib/mocks: dependencies: '@contentful/optimization-api-schemas': @@ -663,7 +669,7 @@ importers: version: 20.8.9 next: specifier: ^16.2.6 - version: 16.2.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 16.2.6(@playwright/test@1.61.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react-router-dom: specifier: ^7.14.2 version: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -2089,6 +2095,11 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@playwright/test@1.61.0': + resolution: {integrity: sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==} + engines: {node: '>=18'} + hasBin: true + '@pm2/agent@2.1.1': resolution: {integrity: sha512-0V9ckHWd/HSC8BgAbZSoq8KXUG81X97nSkAxmhKDhmF8vanyaoc1YXwc2KVkbWz82Rg4gjd2n9qiT3i7bdvGrQ==} @@ -4543,6 +4554,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -5975,7 +5991,7 @@ packages: hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 - '@playwright/test': ^1.57.0 + '@playwright/test': 1.61.0 babel-plugin-react-compiler: '*' react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 @@ -6316,6 +6332,16 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + playwright-core@1.61.0: + resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.0: + resolution: {integrity: sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==} + engines: {node: '>=18'} + hasBin: true + pm2-axon-rpc@0.7.1: resolution: {integrity: sha512-FbLvW60w+vEyvMjP/xom2UPhUN/2bVpdtLfKJeYM3gwzYhoTEEChCOICfFzxkxuoEleOlnpjie+n1nue91bDQw==} engines: {node: '>=5'} @@ -9111,6 +9137,10 @@ snapshots: '@pkgr/core@0.2.9': {} + '@playwright/test@1.61.0': + dependencies: + playwright: 1.61.0 + '@pm2/agent@2.1.1': dependencies: async: 3.2.6 @@ -12368,6 +12398,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -14262,7 +14295,7 @@ snapshots: netmask@2.0.2: {} - next@16.2.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + next@16.2.6(@playwright/test@1.61.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: '@next/env': 16.2.6 '@swc/helpers': 0.5.15 @@ -14281,6 +14314,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.2.6 '@next/swc-win32-arm64-msvc': 16.2.6 '@next/swc-win32-x64-msvc': 16.2.6 + '@playwright/test': 1.61.0 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -14593,6 +14627,14 @@ snapshots: dependencies: find-up: 4.1.0 + playwright-core@1.61.0: {} + + playwright@1.61.0: + dependencies: + playwright-core: 1.61.0 + optionalDependencies: + fsevents: 2.3.2 + pm2-axon-rpc@0.7.1: dependencies: debug: 4.4.3 From df930aa5f031983f938863f00e2264bd8d5c825e Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Wed, 17 Jun 2026 00:27:19 +0200 Subject: [PATCH 26/30] chore(e2e-web): drop implementation: prefix from playwright install scripts [NT-3466] Co-Authored-By: Claude Sonnet 4.6 --- lib/e2e-web/package.json | 6 +++--- package.json | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/e2e-web/package.json b/lib/e2e-web/package.json index 5cc6f47a..f504b5df 100644 --- a/lib/e2e-web/package.json +++ b/lib/e2e-web/package.json @@ -8,9 +8,9 @@ "test": "playwright test", "test:ui": "playwright test --ui", "test:report": "playwright show-report", - "implementation:playwright:install": "playwright install", - "implementation:playwright:install-deps": "playwright install-deps", - "implementation:setup:e2e": "pnpm implementation:playwright:install && pnpm implementation:playwright:install-deps", + "playwright:install": "playwright install", + "playwright:install-deps": "playwright install-deps", + "implementation:setup:e2e": "pnpm playwright:install && pnpm playwright:install-deps", "test:unit": "echo \"No unit tests necessary\"" }, "devDependencies": { diff --git a/package.json b/package.json index f029d0ac..ff27dfbd 100644 --- a/package.json +++ b/package.json @@ -38,8 +38,8 @@ "notices:generate:npm": "bash ./scripts/generate-third-party-notices.sh npm", "notices:generate:swift": "bash ./scripts/generate-third-party-notices.sh swift", "pack:pkgs": "bash ./scripts/pack-pkgs.sh", - "playwright:install": "pnpm run implementation:run -- --all -- implementation:playwright:install && pnpm --dir lib/e2e-web run implementation:playwright:install", - "playwright:install-deps": "pnpm run implementation:run -- --all -- implementation:playwright:install-deps && pnpm --dir lib/e2e-web run implementation:playwright:install-deps", + "playwright:install": "pnpm run implementation:run -- --all -- implementation:playwright:install && pnpm --dir lib/e2e-web run playwright:install", + "playwright:install-deps": "pnpm run implementation:run -- --all -- implementation:playwright:install-deps && pnpm --dir lib/e2e-web run playwright:install-deps", "pm2:delete:all": "pm2 delete all", "pm2:list": "pm2 list", "pm2:logs": "pm2 logs", From 9c14e41259fbf127616976c5e2921b0b20bc1ca3 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Wed, 17 Jun 2026 00:27:46 +0200 Subject: [PATCH 27/30] chore(e2e-web): drop implementation: prefix from setup:e2e script [NT-3466] Co-Authored-By: Claude Sonnet 4.6 --- implementations/web-sdk_angular/package.json | 2 +- implementations/web-sdk_react/package.json | 2 +- lib/e2e-web/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/implementations/web-sdk_angular/package.json b/implementations/web-sdk_angular/package.json index c0bde0a3..d2ac3170 100644 --- a/implementations/web-sdk_angular/package.json +++ b/implementations/web-sdk_angular/package.json @@ -18,7 +18,7 @@ "test:e2e": "pnpm test:e2e:setup && pnpm --dir ../../lib/e2e-web test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", "test:e2e:ui": "pnpm test:e2e:setup && pnpm --dir ../../lib/e2e-web test:ui; pnpm serve:stop;", "test:e2e:report": "pnpm --dir ../../lib/e2e-web test:report", - "implementation:setup:e2e": "pnpm --dir ../../lib/e2e-web implementation:setup:e2e", + "implementation:setup:e2e": "pnpm --dir ../../lib/e2e-web setup:e2e", "test:unit": "echo \"No unit tests necessary\"", "typecheck": "tsc -p tsconfig.json --noEmit" }, diff --git a/implementations/web-sdk_react/package.json b/implementations/web-sdk_react/package.json index c389c4d4..c857e38d 100644 --- a/implementations/web-sdk_react/package.json +++ b/implementations/web-sdk_react/package.json @@ -20,7 +20,7 @@ "test:e2e": "pnpm test:e2e:setup && pnpm --dir ../../lib/e2e-web test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", "test:e2e:ui": "pnpm test:e2e:setup && pnpm --dir ../../lib/e2e-web test:ui; pnpm serve:stop", "test:e2e:report": "pnpm --dir ../../lib/e2e-web test:report", - "implementation:setup:e2e": "pnpm --dir ../../lib/e2e-web implementation:setup:e2e", + "implementation:setup:e2e": "pnpm --dir ../../lib/e2e-web setup:e2e", "test:unit": "echo \"No unit tests necessary\"", "typecheck": "tsc --noEmit" }, diff --git a/lib/e2e-web/package.json b/lib/e2e-web/package.json index f504b5df..c6f501c7 100644 --- a/lib/e2e-web/package.json +++ b/lib/e2e-web/package.json @@ -10,7 +10,7 @@ "test:report": "playwright show-report", "playwright:install": "playwright install", "playwright:install-deps": "playwright install-deps", - "implementation:setup:e2e": "pnpm playwright:install && pnpm playwright:install-deps", + "setup:e2e": "pnpm playwright:install && pnpm playwright:install-deps", "test:unit": "echo \"No unit tests necessary\"" }, "devDependencies": { From 904bdd135104d086813063d6372d2cfef88607c0 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Wed, 17 Jun 2026 00:32:17 +0200 Subject: [PATCH 28/30] fix(e2e-web): replace echo with tail -f /dev/null for Playwright 1.61 webServer compat [NT-3466] Co-Authored-By: Claude Sonnet 4.6 --- lib/e2e-web/playwright.config.mjs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/e2e-web/playwright.config.mjs b/lib/e2e-web/playwright.config.mjs index cc751ff8..a682f1e9 100644 --- a/lib/e2e-web/playwright.config.mjs +++ b/lib/e2e-web/playwright.config.mjs @@ -25,7 +25,9 @@ export default defineConfig({ ], webServer: [ { - command: 'echo "server managed externally"', + // Playwright 1.61+ requires the command process to stay alive until the URL + // is reachable. Servers are managed externally (PM2); tail keeps the slot open. + command: 'tail -f /dev/null', url: process.env.BASE_URL ?? 'http://localhost:3000', reuseExistingServer: true, timeout: 120000, @@ -34,7 +36,7 @@ export default defineConfig({ // Wait for the mocks server (port 8000) before starting tests. // PM2 returns immediately after forking — without this poll, the first // API call races the tsx startup and hangs until the 60s test timeout. - command: 'echo "mocks managed externally"', + command: 'tail -f /dev/null', url: process.env.MOCKS_URL ?? 'http://localhost:8000', reuseExistingServer: true, timeout: 30000, From efe2d3ede42a608d384ec4a6381c6c14e2cb1e11 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Wed, 17 Jun 2026 00:45:57 +0200 Subject: [PATCH 29/30] fix: no web servers --- lib/e2e-web/playwright.config.mjs | 38 +++++++++++++++---------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/lib/e2e-web/playwright.config.mjs b/lib/e2e-web/playwright.config.mjs index a682f1e9..ca32ce5a 100644 --- a/lib/e2e-web/playwright.config.mjs +++ b/lib/e2e-web/playwright.config.mjs @@ -23,23 +23,23 @@ export default defineConfig({ { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, { name: 'webkit', use: { ...devices['Desktop Safari'] } }, ], - webServer: [ - { - // Playwright 1.61+ requires the command process to stay alive until the URL - // is reachable. Servers are managed externally (PM2); tail keeps the slot open. - command: 'tail -f /dev/null', - url: process.env.BASE_URL ?? 'http://localhost:3000', - reuseExistingServer: true, - timeout: 120000, - }, - { - // Wait for the mocks server (port 8000) before starting tests. - // PM2 returns immediately after forking — without this poll, the first - // API call races the tsx startup and hangs until the 60s test timeout. - command: 'tail -f /dev/null', - url: process.env.MOCKS_URL ?? 'http://localhost:8000', - reuseExistingServer: true, - timeout: 30000, - }, - ], + // webServer: [ + // { + // // Playwright 1.61+ requires the command process to stay alive until the URL + // // is reachable. Servers are managed externally (PM2); tail keeps the slot open. + // command: 'tail -f /dev/null', + // url: process.env.BASE_URL ?? 'http://localhost:3000', + // reuseExistingServer: true, + // timeout: 120000, + // }, + // { + // // Wait for the mocks server (port 8000) before starting tests. + // // PM2 returns immediately after forking — without this poll, the first + // // API call races the tsx startup and hangs until the 60s test timeout. + // command: 'tail -f /dev/null', + // url: process.env.MOCKS_URL ?? 'http://localhost:8000', + // reuseExistingServer: true, + // timeout: 30000, + // }, + // ], }) From cac063fa89f207879ae257f7afce097c7af4192f Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Wed, 17 Jun 2026 10:30:39 +0200 Subject: [PATCH 30/30] fix(e2e-web): add @types/node so process.env typechecks [NT-3466] Co-Authored-By: Claude Sonnet 4.6 --- lib/e2e-web/package.json | 3 ++- lib/e2e-web/tsconfig.json | 3 ++- pnpm-lock.yaml | 3 +++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/e2e-web/package.json b/lib/e2e-web/package.json index c6f501c7..b6ddfe40 100644 --- a/lib/e2e-web/package.json +++ b/lib/e2e-web/package.json @@ -14,6 +14,7 @@ "test:unit": "echo \"No unit tests necessary\"" }, "devDependencies": { - "@playwright/test": "catalog:" + "@playwright/test": "catalog:", + "@types/node": "catalog:" } } diff --git a/lib/e2e-web/tsconfig.json b/lib/e2e-web/tsconfig.json index 7d8d997d..7dbee1af 100644 --- a/lib/e2e-web/tsconfig.json +++ b/lib/e2e-web/tsconfig.json @@ -9,7 +9,8 @@ "noUnusedParameters": true, "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, - "skipLibCheck": true + "skipLibCheck": true, + "types": ["node"] }, "include": ["e2e/**/*.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1fcc4a28..7b248c11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -185,6 +185,9 @@ importers: '@playwright/test': specifier: 1.61.0 version: 1.61.0 + '@types/node': + specifier: ^24.0.13 + version: 24.10.13 lib/mocks: dependencies:
{{ event.value }} + @if (event.hoverDurationMs !== undefined) { {{ event.type }} - Entry/Flag: {{ event.value }} + - Hover Duration: {{ event.hoverDurationMs }}ms } @else if (event.viewDurationMs !== + undefined) { {{ event.type }} - Entry/Flag: {{ event.value }} - Duration: {{ + event.viewDurationMs }}ms } @else if (event.type === 'page') { {{ event.type }} - URL: {{ + event.value }} } @else { {{ event.type }} - Entry/Flag: {{ event.value }} } + @if (event.count > 1) { ×{{ event.count }} }