From f7d1008f45bf8775989813fb07dc45e81eb3ddad Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Tue, 16 Jun 2026 11:11:09 +0200 Subject: [PATCH 1/2] 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 2/2] 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, {
{{ 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 }} }