Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*.seed
*.tsbuildinfo
.DS_Store
.cache
.contentfulrc.json
.env
.pnpm-store
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { expect, test, type Locator, type Page } from '@playwright/test'

interface HoverScenario {
name: string
entryTestId: string
hoverTargetTestId: string
}

const hoverScenarios: HoverScenario[] = [
{
name: 'direct entry button',
entryTestId: 'entry-click-direct-entry',
hoverTargetTestId: 'entry-click-direct-entry',
},
{
name: 'hoverable descendant button',
entryTestId: 'entry-click-descendant-entry',
hoverTargetTestId: 'entry-click-descendant-button',
},
{
name: 'inline entry nested in clickable ancestor',
entryTestId: 'entry-click-ancestor-entry',
hoverTargetTestId: 'entry-click-ancestor-entry',
},
]

async function movePointerAwayFromEntries(page: Page): Promise<void> {
await page.locator('#utility-panel').hover()
}

async function readHoverDurationMs(button: Locator): Promise<number> {
const hoverDurationMs = await button.getAttribute('data-hover-duration-ms')
if (!hoverDurationMs) return Number.NaN

return Number.parseInt(hoverDurationMs, 10)
}

function getComponentHoverButtons(page: Page): Locator {
return page
.locator('#event-stream li button[data-component-hover-id]')
.filter({ hasText: 'component_hover' })
}

test.describe('entry hover tracking', () => {
test.describe('without consent', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('domcontentloaded')
})

test('does not emit component_hover events', 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('#event-stream li button', {
hasText: 'component_hover',
}),
).toHaveCount(0)
})
})

test.describe('with consent', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('domcontentloaded')
await page.getByRole('button', { name: 'Accept Consent' }).click()
await expect(page.getByRole('button', { name: 'Reject Consent' })).toBeVisible()
})

test('emits component_hover events for direct, descendant, and inline targets', async ({
page,
}) => {
const hoverButtons = getComponentHoverButtons(page)

for (const scenario of hoverScenarios) {
const entryLocator = page.getByTestId(scenario.entryTestId)
await expect(entryLocator, `${scenario.name}: entry should render`).toBeVisible()

const target = page.getByTestId(scenario.hoverTargetTestId)
const baselineHoverEventCount = await hoverButtons.count()

await target.scrollIntoViewIfNeeded()
await target.hover()

await expect
.poll(async () => await hoverButtons.count(), {
message: `${scenario.name}: hover event count should increase`,
})
.toBeGreaterThan(baselineHoverEventCount)

await expect(hoverButtons.last()).toHaveAttribute('data-component-hover-id', /.+/)

await movePointerAwayFromEntries(page)
}

await expect.poll(async () => await hoverButtons.count()).toBeGreaterThanOrEqual(3)
})

test('updates hover duration while hovered and emits a final update after hover ends', async ({
page,
}) => {
const scenario = hoverScenarios[0]
if (!scenario) return

const target = page.getByTestId(scenario.hoverTargetTestId)
const hoverButtons = getComponentHoverButtons(page)
const baselineHoverEventCount = await hoverButtons.count()
await target.scrollIntoViewIfNeeded()
await target.hover()

await expect
.poll(async () => await hoverButtons.count(), {
message: `${scenario.name}: initial hover event should be emitted`,
})
.toBeGreaterThan(baselineHoverEventCount)

const hoverSessionButton = hoverButtons.last()

await expect(hoverSessionButton).toBeVisible()

const componentHoverId = await hoverSessionButton.getAttribute('data-component-hover-id')
expect(componentHoverId).toBeTruthy()

await expect
.poll(async () => await readHoverDurationMs(hoverSessionButton))
.toBeGreaterThanOrEqual(1000)
const firstHoverDurationMs = await readHoverDurationMs(hoverSessionButton)

await expect
.poll(async () => await readHoverDurationMs(hoverSessionButton))
.toBeGreaterThan(firstHoverDurationMs)
const updatedHoverDurationMs = await readHoverDurationMs(hoverSessionButton)

const updatedComponentHoverId =
await hoverSessionButton.getAttribute('data-component-hover-id')
expect(updatedComponentHoverId).toEqual(componentHoverId)

await page.waitForTimeout(300)
await movePointerAwayFromEntries(page)

await expect
.poll(async () => await readHoverDurationMs(hoverSessionButton))
.toBeGreaterThan(updatedHoverDurationMs)
const finalHoverDurationMs = await readHoverDurationMs(hoverSessionButton)

const finalComponentHoverId = await hoverSessionButton.getAttribute('data-component-hover-id')
expect(finalComponentHoverId).toEqual(componentHoverId)
expect(finalHoverDurationMs).toBeGreaterThan(firstHoverDurationMs)
})
})
})
42 changes: 41 additions & 1 deletion implementations/node-ssr-web-vanilla/src/index.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -153,18 +153,21 @@
type="button"
data-testid="entry-click-direct-entry"
data-e2e-click-scenario="direct"
data-ctfl-hover-duration-update-interval-ms="1000"
data-ctfl-entry-id="4ib0hsHWoSOnCVdDkizE8d"
></button>
<div
data-testid="entry-click-descendant-entry"
data-e2e-click-scenario="descendant"
data-ctfl-hover-duration-update-interval-ms="1000"
data-ctfl-entry-id="xFwgG3oNaOcjzWiGe4vXo"
></div>
<button type="button" data-testid="entry-click-ancestor-wrapper">
<span
data-testid="entry-click-ancestor-entry"
data-e2e-click-scenario="ancestor"
data-e2e-entry-inline="true"
data-ctfl-hover-duration-update-interval-ms="1000"
data-ctfl-entry-id="2Z2WLOx07InSewC3LUB3eX"
></span>
</button>
Expand Down Expand Up @@ -205,7 +208,7 @@
const contentfulClient = contentful.createClient(CONFIG.contentful)
window.optimization = new Optimization({
...CONFIG.optimization,
autoTrackEntryInteraction: { views: true, clicks: true },
autoTrackEntryInteraction: { views: true, clicks: true, hovers: true },
app: { name: document.title, version: '0.0.0' },
})
const optimization = window.optimization
Expand Down Expand Up @@ -238,6 +241,7 @@

const eventStream = document.querySelector('#event-stream')
const componentViewEventDialogs = new Map()
const componentHoverEventDialogs = new Map()

function isComponentViewHeartbeatEvent(event) {
return (
Expand All @@ -248,20 +252,39 @@
)
}

function isComponentHoverHeartbeatEvent(event) {
return (
event?.type === 'component_hover' &&
typeof event?.componentHoverId === 'string' &&
event.componentHoverId.length > 0 &&
typeof event?.hoverDurationMs === 'number'
)
}

function updateEventDialog(button, pre, title, details) {
const componentViewId =
typeof details?.componentViewId === 'string' ? details.componentViewId : undefined
const componentHoverId =
typeof details?.componentHoverId === 'string' ? details.componentHoverId : undefined
const componentId = typeof details?.componentId === 'string' ? details.componentId : undefined
const hoverDurationMs =
typeof details?.hoverDurationMs === 'number' ? details.hoverDurationMs : undefined

button.innerText = title
button.dataset.testid = details.componentId ?? details?.properties?.url ?? details.messageId

if (componentViewId) button.dataset.componentViewId = componentViewId
else delete button.dataset.componentViewId

if (componentHoverId) button.dataset.componentHoverId = componentHoverId
else delete button.dataset.componentHoverId

if (componentId) button.dataset.componentId = componentId
else delete button.dataset.componentId

if (hoverDurationMs !== undefined) button.dataset.hoverDurationMs = String(hoverDurationMs)
else delete button.dataset.hoverDurationMs

pre.innerText = JSON.stringify(details, null, 2)
}

Expand Down Expand Up @@ -468,6 +491,23 @@
return
}

if (isComponentHoverHeartbeatEvent(event)) {
const existingDialog = componentHoverEventDialogs.get(event.componentHoverId)

if (existingDialog) {
updateEventDialog(existingDialog.button, existingDialog.pre, event.type, event)
return
}

const newDialog = createEventDialog(event.type, event)
componentHoverEventDialogs.set(event.componentHoverId, {
button: newDialog.button,
pre: newDialog.pre,
})
eventStream.appendChild(newDialog.fragment)
return
}

eventStream.appendChild(createEventDialog(event.type, event).fragment)
})

Expand Down
78 changes: 78 additions & 0 deletions implementations/web-react/e2e/entry-click-tracking.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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)
})
})
Loading