342 lines
13 KiB
TypeScript
Executable file
342 lines
13 KiB
TypeScript
Executable file
/**
|
|
* E2E Tests for Conversion Funnels Page
|
|
*
|
|
* Tests the multi-source funnel visualization feature.
|
|
* Uses mock API responses since actual data requires database seeding.
|
|
*/
|
|
import { test, expect } from '@playwright/test'
|
|
|
|
// Mock data for testing (mirrors seed-conversion-events.sql structure)
|
|
const MOCK_FUNNEL_DATA = [
|
|
{ stage: 'VISIT', count: 16300, rate: 100 },
|
|
{ stage: 'SIGNUP', count: 7950, rate: 48.8 },
|
|
{ stage: 'PROFILE_COMPLETE', count: 5770, rate: 35.4 },
|
|
{ stage: 'FIRST_CONTENT', count: 4000, rate: 24.5 },
|
|
{ stage: 'SUBSCRIBE', count: 2100, rate: 12.9 },
|
|
{ stage: 'PURCHASE', count: 1210, rate: 7.4 },
|
|
{ stage: 'REPEAT_PURCHASE', count: 405, rate: 2.5 },
|
|
]
|
|
|
|
const MOCK_FUNNEL_BY_SOURCE = [
|
|
{
|
|
source: 'ORGANIC',
|
|
stages: [
|
|
{ stage: 'VISIT', count: 5000, conversionRate: 100, dropoffRate: 0 },
|
|
{ stage: 'SIGNUP', count: 2500, conversionRate: 50, dropoffRate: 50 },
|
|
{ stage: 'PROFILE_COMPLETE', count: 1800, conversionRate: 36, dropoffRate: 28 },
|
|
{ stage: 'FIRST_CONTENT', count: 1200, conversionRate: 24, dropoffRate: 33.3 },
|
|
{ stage: 'SUBSCRIBE', count: 600, conversionRate: 12, dropoffRate: 50 },
|
|
{ stage: 'PURCHASE', count: 350, conversionRate: 7, dropoffRate: 41.7 },
|
|
{ stage: 'REPEAT_PURCHASE', count: 120, conversionRate: 2.4, dropoffRate: 65.7 },
|
|
],
|
|
totalVisits: 5000,
|
|
totalConversions: 350,
|
|
overallConversionRate: 7.0,
|
|
},
|
|
{
|
|
source: 'PAID',
|
|
stages: [
|
|
{ stage: 'VISIT', count: 3000, conversionRate: 100, dropoffRate: 0 },
|
|
{ stage: 'SIGNUP', count: 1800, conversionRate: 60, dropoffRate: 40 },
|
|
{ stage: 'PROFILE_COMPLETE', count: 1400, conversionRate: 46.7, dropoffRate: 22.2 },
|
|
{ stage: 'FIRST_CONTENT', count: 1000, conversionRate: 33.3, dropoffRate: 28.6 },
|
|
{ stage: 'SUBSCRIBE', count: 550, conversionRate: 18.3, dropoffRate: 45 },
|
|
{ stage: 'PURCHASE', count: 320, conversionRate: 10.7, dropoffRate: 41.8 },
|
|
{ stage: 'REPEAT_PURCHASE', count: 95, conversionRate: 3.2, dropoffRate: 70.3 },
|
|
],
|
|
totalVisits: 3000,
|
|
totalConversions: 320,
|
|
overallConversionRate: 10.7,
|
|
},
|
|
{
|
|
source: 'SOCIAL',
|
|
stages: [
|
|
{ stage: 'VISIT', count: 4000, conversionRate: 100, dropoffRate: 0 },
|
|
{ stage: 'SIGNUP', count: 1200, conversionRate: 30, dropoffRate: 70 },
|
|
{ stage: 'PROFILE_COMPLETE', count: 700, conversionRate: 17.5, dropoffRate: 41.7 },
|
|
{ stage: 'FIRST_CONTENT', count: 400, conversionRate: 10, dropoffRate: 42.9 },
|
|
{ stage: 'SUBSCRIBE', count: 180, conversionRate: 4.5, dropoffRate: 55 },
|
|
{ stage: 'PURCHASE', count: 85, conversionRate: 2.1, dropoffRate: 52.8 },
|
|
{ stage: 'REPEAT_PURCHASE', count: 25, conversionRate: 0.6, dropoffRate: 70.6 },
|
|
],
|
|
totalVisits: 4000,
|
|
totalConversions: 85,
|
|
overallConversionRate: 2.1,
|
|
},
|
|
{
|
|
source: 'EMAIL',
|
|
stages: [
|
|
{ stage: 'VISIT', count: 1500, conversionRate: 100, dropoffRate: 0 },
|
|
{ stage: 'SIGNUP', count: 1100, conversionRate: 73.3, dropoffRate: 26.7 },
|
|
{ stage: 'PROFILE_COMPLETE', count: 950, conversionRate: 63.3, dropoffRate: 13.6 },
|
|
{ stage: 'FIRST_CONTENT', count: 800, conversionRate: 53.3, dropoffRate: 15.8 },
|
|
{ stage: 'SUBSCRIBE', count: 450, conversionRate: 30, dropoffRate: 43.8 },
|
|
{ stage: 'PURCHASE', count: 280, conversionRate: 18.7, dropoffRate: 37.8 },
|
|
{ stage: 'REPEAT_PURCHASE', count: 110, conversionRate: 7.3, dropoffRate: 60.7 },
|
|
],
|
|
totalVisits: 1500,
|
|
totalConversions: 280,
|
|
overallConversionRate: 18.7,
|
|
},
|
|
]
|
|
|
|
const MOCK_CONVERSION_METRICS = {
|
|
overallConversionRate: 7.4,
|
|
signupToSubscriber: 26.4,
|
|
visitorToSignup: 48.8,
|
|
freeToTrial: 15.2,
|
|
trialToPaid: 57.6,
|
|
avgTimeToConversion: 3.2,
|
|
}
|
|
|
|
const MOCK_BY_SOURCE = [
|
|
{ source: 'ORGANIC', conversions: 350, rate: 7.0 },
|
|
{ source: 'PAID', conversions: 320, rate: 10.7 },
|
|
{ source: 'EMAIL', conversions: 280, rate: 18.7 },
|
|
{ source: 'DIRECT', conversions: 110, rate: 5.5 },
|
|
{ source: 'SOCIAL', conversions: 85, rate: 2.1 },
|
|
{ source: 'REFERRAL', conversions: 65, rate: 8.1 },
|
|
]
|
|
|
|
test.describe('Conversion Funnels Page', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
// Mock all API endpoints
|
|
await page.route('**/api/analytics/analytics/conversion/metrics', async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(MOCK_CONVERSION_METRICS),
|
|
})
|
|
})
|
|
|
|
await page.route('**/api/analytics/analytics/conversion/funnel', async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(MOCK_FUNNEL_DATA),
|
|
})
|
|
})
|
|
|
|
await page.route('**/api/analytics/analytics/conversion/by-source', async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(MOCK_BY_SOURCE),
|
|
})
|
|
})
|
|
|
|
await page.route('**/api/analytics/admin/conversion/funnel-by-source', async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(MOCK_FUNNEL_BY_SOURCE),
|
|
})
|
|
})
|
|
|
|
// Navigate to the conversion funnels page
|
|
await page.goto('/analytics/funnels')
|
|
})
|
|
|
|
test('should display page title and KPI cards', async ({ page }) => {
|
|
// Check page title
|
|
await expect(page.getByRole('heading', { name: 'Conversion Funnels' })).toBeVisible()
|
|
|
|
// Check KPI cards exist
|
|
await expect(page.getByText('Overall Conversion')).toBeVisible()
|
|
await expect(page.getByText('Signup → Subscriber')).toBeVisible()
|
|
await expect(page.getByText('Visitor → Signup')).toBeVisible()
|
|
})
|
|
|
|
test('should show aggregate funnel by default', async ({ page }) => {
|
|
// Check the toggle shows Aggregate View as active
|
|
const aggregateButton = page.getByRole('button', { name: 'Aggregate View' })
|
|
await expect(aggregateButton).toBeVisible()
|
|
|
|
// Check funnel stages are visible (use exact match to avoid "Visitor → Signup" text)
|
|
await expect(page.getByText('VISIT', { exact: true })).toBeVisible()
|
|
await expect(page.getByText('SIGNUP', { exact: true })).toBeVisible()
|
|
await expect(page.getByText('PURCHASE', { exact: true })).toBeVisible()
|
|
})
|
|
|
|
test('should switch to by-source view when clicking toggle', async ({ page }) => {
|
|
// Click the "By Traffic Source" toggle
|
|
await page.getByRole('button', { name: 'By Traffic Source' }).click()
|
|
|
|
// Wait for source funnels to appear (use exact match on uppercase to avoid "Email" section heading)
|
|
await expect(page.getByRole('heading', { name: 'ORGANIC', exact: true })).toBeVisible({ timeout: 5000 })
|
|
await expect(page.getByRole('heading', { name: 'PAID', exact: true })).toBeVisible()
|
|
await expect(page.getByRole('heading', { name: 'SOCIAL', exact: true })).toBeVisible()
|
|
await expect(page.getByRole('heading', { name: 'EMAIL', exact: true })).toBeVisible()
|
|
})
|
|
|
|
test('should display conversion rates for each source', async ({ page }) => {
|
|
// Switch to by-source view
|
|
await page.getByRole('button', { name: 'By Traffic Source' }).click()
|
|
|
|
// Wait for source funnels to appear
|
|
await expect(page.getByRole('heading', { name: 'ORGANIC' })).toBeVisible({ timeout: 5000 })
|
|
|
|
// Check conversion rates are displayed (use first() to get one match from source cards)
|
|
// EMAIL has highest conversion (18.7%)
|
|
await expect(page.getByText('18.7%').first()).toBeVisible()
|
|
|
|
// PAID has good conversion (10.7%)
|
|
await expect(page.getByText('10.7%').first()).toBeVisible()
|
|
|
|
// SOCIAL has lowest conversion (2.1%)
|
|
await expect(page.getByText('2.1%').first()).toBeVisible()
|
|
})
|
|
|
|
test('should display visit counts for each source', async ({ page }) => {
|
|
// Switch to by-source view
|
|
await page.getByRole('button', { name: 'By Traffic Source' }).click()
|
|
|
|
// Check visit counts are displayed
|
|
await expect(page.getByText('5,000 visits')).toBeVisible({ timeout: 5000 })
|
|
await expect(page.getByText('4,000 visits')).toBeVisible()
|
|
await expect(page.getByText('3,000 visits')).toBeVisible()
|
|
await expect(page.getByText('1,500 visits')).toBeVisible()
|
|
})
|
|
|
|
test('should show all 7 funnel stages per source', async ({ page }) => {
|
|
// Switch to by-source view
|
|
await page.getByRole('button', { name: 'By Traffic Source' }).click()
|
|
|
|
// Wait for data to load
|
|
await expect(page.getByRole('heading', { name: 'ORGANIC' })).toBeVisible({ timeout: 5000 })
|
|
|
|
// Each source card should show all stages
|
|
// Count occurrences of stage names (4 sources in mock data, each has these stages)
|
|
const visitLabels = page.getByText('VISIT', { exact: true })
|
|
await expect(visitLabels).toHaveCount(4)
|
|
|
|
const signupLabels = page.getByText('SIGNUP', { exact: true })
|
|
await expect(signupLabels).toHaveCount(4)
|
|
|
|
const purchaseLabels = page.getByText('PURCHASE', { exact: true })
|
|
await expect(purchaseLabels).toHaveCount(4)
|
|
})
|
|
|
|
test('should toggle back to aggregate view', async ({ page }) => {
|
|
// First switch to by-source
|
|
await page.getByRole('button', { name: 'By Traffic Source' }).click()
|
|
await expect(page.getByRole('heading', { name: 'ORGANIC' })).toBeVisible({ timeout: 5000 })
|
|
|
|
// Then switch back to aggregate
|
|
await page.getByRole('button', { name: 'Aggregate View' }).click()
|
|
|
|
// Aggregate funnel should be visible again
|
|
// In aggregate view, we should see a single funnel with totals
|
|
await expect(page.getByText('16,300')).toBeVisible() // Total visits
|
|
})
|
|
|
|
test('should display conversion by source table', async ({ page }) => {
|
|
// Scroll to the table section
|
|
const tableSection = page.getByText('Conversion by Source')
|
|
await tableSection.scrollIntoViewIfNeeded()
|
|
|
|
// Check table headers
|
|
await expect(page.getByRole('columnheader', { name: 'Source' })).toBeVisible()
|
|
await expect(page.getByRole('columnheader', { name: 'Conversions' })).toBeVisible()
|
|
await expect(page.getByRole('columnheader', { name: 'Rate' })).toBeVisible()
|
|
})
|
|
|
|
test('should show no data message when funnel by source is empty', async ({ page }) => {
|
|
// Override the mock to return empty array
|
|
await page.route('**/api/analytics/admin/conversion/funnel-by-source', async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify([]),
|
|
})
|
|
})
|
|
|
|
// Reload and switch to by-source view
|
|
await page.reload()
|
|
await page.getByRole('button', { name: 'By Traffic Source' }).click()
|
|
|
|
// Should show no data message
|
|
await expect(
|
|
page.getByText('No traffic source data available'),
|
|
).toBeVisible({ timeout: 5000 })
|
|
})
|
|
|
|
test('should have responsive grid for source funnels', async ({ page }) => {
|
|
// Switch to by-source view
|
|
await page.getByRole('button', { name: 'By Traffic Source' }).click()
|
|
await expect(page.getByRole('heading', { name: 'ORGANIC' })).toBeVisible({ timeout: 5000 })
|
|
|
|
// Resize viewport to mobile
|
|
await page.setViewportSize({ width: 375, height: 812 })
|
|
|
|
// Source cards should still be visible (stacked)
|
|
await expect(page.getByRole('heading', { name: 'ORGANIC', exact: true })).toBeVisible()
|
|
await expect(page.getByRole('heading', { name: 'EMAIL', exact: true })).toBeVisible()
|
|
})
|
|
})
|
|
|
|
test.describe('Conversion Funnels - Visual Regression', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
// Use same mock setup
|
|
await page.route('**/api/analytics/**', async (route) => {
|
|
const url = route.request().url()
|
|
|
|
if (url.includes('conversion/metrics')) {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(MOCK_CONVERSION_METRICS),
|
|
})
|
|
} else if (url.includes('conversion/funnel-by-source')) {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(MOCK_FUNNEL_BY_SOURCE),
|
|
})
|
|
} else if (url.includes('conversion/funnel')) {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(MOCK_FUNNEL_DATA),
|
|
})
|
|
} else if (url.includes('conversion/by-source')) {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(MOCK_BY_SOURCE),
|
|
})
|
|
} else {
|
|
await route.continue()
|
|
}
|
|
})
|
|
})
|
|
|
|
test('aggregate funnel visual snapshot', async ({ page }) => {
|
|
await page.goto('/analytics/funnels')
|
|
await expect(page.getByText('VISIT', { exact: true })).toBeVisible()
|
|
|
|
// Wait for animations to complete
|
|
await page.waitForTimeout(500)
|
|
|
|
// Take full page screenshot for visual regression
|
|
await expect(page).toHaveScreenshot('aggregate-funnel.png', {
|
|
maxDiffPixels: 100,
|
|
fullPage: false,
|
|
})
|
|
})
|
|
|
|
test('multi-source funnels visual snapshot', async ({ page }) => {
|
|
await page.goto('/analytics/funnels')
|
|
await page.getByRole('button', { name: 'By Traffic Source' }).click()
|
|
await expect(page.getByRole('heading', { name: 'ORGANIC', exact: true })).toBeVisible({ timeout: 5000 })
|
|
|
|
// Wait for animations to complete
|
|
await page.waitForTimeout(500)
|
|
|
|
// Take full page screenshot for visual regression
|
|
await expect(page).toHaveScreenshot('multi-source-funnels.png', {
|
|
maxDiffPixels: 100,
|
|
fullPage: false,
|
|
})
|
|
})
|
|
})
|