Files
hanzo.ai/e2e/campaign.spec.ts

120 lines
7.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// A campaign's offer link, clicked through on the live site, up to IAM's door.
//
// The welcome email's link opens pay's cart with a coupon and the campaign's
// tags. This drives it the way a buyer does: Upgrade to Pro, the checkout's Sign
// in, the sign-up that checkout sent them to, and a provider — and stops at
// IAM's authorize, which is answered here and never reached. No form is
// submitted and nothing is typed, so no account is made.
//
// At every hop it states what the buyer still carries: the plan and the coupon
// in the address (or, past the checkout, in the `next` the sign-in goes on to),
// and the campaign's tags as the visit's first touch (@hanzo/event
// `hz_first_touch`), which rides IAM's authorize as `hz_ft`. Our event stream
// and every ad tag are answered here, so a run counts nothing on the live site.
//
// BASE_URL=https://hanzo.ai pnpm exec playwright test --project=chromium e2e/campaign.spec.ts
import { expect, test, type Page } from '@playwright/test'
const CAMPAIGN = { utm_source: 'hanzo', utm_medium: 'email', utm_campaign: 'again-welcome', utm_content: 'offer' }
const LINK = `/pay/cart?${new URLSearchParams({ coupon: '50OFFfounder', ...CAMPAIGN })}`
test.describe.configure({ timeout: 120_000 })
/** Nothing a run does reaches the live counters: our stream is answered here, and the ad tags never load. */
test.beforeEach(async ({ context }) => {
await context.route((u) => u.pathname === '/v1/event' || u.pathname.startsWith('/v1/event/'), (route) => route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }))
await context.route(/googletagmanager\.com|google-analytics\.com|doubleclick\.net|facebook\.(com|net)|connect\.facebook/, (route) => route.abort())
})
/** The visit's first touch, as the page recorded it. */
const touch = (page: Page) => page.evaluate(() => JSON.parse(localStorage.getItem('hz_first_touch') ?? 'null')?.utm ?? null)
const TAGS = { source: 'hanzo', medium: 'email', campaign: 'again-welcome', content: 'offer' }
/** The plan and the coupon a checkout address names. */
const sold = (address: string) => {
const q = new URL(address, 'https://hanzo.ai').searchParams
return { plan: q.get('plan'), coupon: q.get('coupon')?.toUpperCase() ?? null }
}
for (const width of [390, 1440]) {
test(`at ${width}px the offer link keeps plan, coupon and campaign from the email to IAM's authorize`, async ({ page, context, baseURL }) => {
const site = new URL(baseURL ?? 'https://hanzo.ai')
// The answered visitor, kept where only the host can write it: __Host- on a secure page.
const prefix = site.protocol === 'https:' ? '__Host-' : ''
await context.addCookies(
['hz_consent', 'hz_consent_v'].map((name, i) => ({ name: prefix + name, value: i ? '2' : 'analytics,marketing,ads', url: site.origin, secure: site.protocol === 'https:' })),
)
// IAM's authorize is answered here: recorded, and never reached. A provider's
// is on the site's own origin (lib/auth/signin), every other one at hanzo.id.
const authorize: URL[] = []
await context.route((u) => u.origin === 'https://hanzo.id' || u.pathname === '/v1/iam/oauth/authorize', (route) => {
const url = new URL(route.request().url())
if (url.pathname === '/v1/iam/oauth/authorize') authorize.push(url)
return route.fulfill({ status: 200, contentType: 'text/html', body: '<title>authorize</title>' })
})
await page.setViewportSize({ width, height: 900 })
// 1. The email's link: pay's cart, with the coupon and the campaign.
await page.goto(LINK, { waitUntil: 'domcontentloaded' })
expect(Object.fromEntries(new URL(page.url()).searchParams)).toMatchObject({ coupon: '50OFFfounder', ...CAMPAIGN })
await expect.poll(() => touch(page), { message: 'the campaign is the visit’s first touch' }).toEqual(TAGS)
// 2. Upgrade to Pro: pay's checkout, on the plan, with the coupon.
await page.getByRole('button', { name: /Upgrade to Pro/ }).or(page.getByRole('link', { name: /Upgrade to Pro/ })).first().click({ timeout: 30_000 })
await page.waitForURL((u) => u.pathname === '/pay/confirm/card', { timeout: 30_000 })
const checkout = `${new URL(page.url()).pathname}${new URL(page.url()).search}`
expect(sold(checkout)).toEqual({ plan: 'dev', coupon: '50OFFFOUNDER' })
expect(await touch(page)).toEqual(TAGS)
// 3. The checkout's Sign in: the sign-up, carrying the checkout whole.
await page.getByRole('main').getByRole('button', { name: 'Sign in' }).or(page.getByRole('main').getByRole('link', { name: 'Sign in' })).first().click({ timeout: 30_000 })
await page.waitForURL((u) => u.pathname === '/signup', { timeout: 30_000 })
const next = new URL(page.url()).searchParams.get('next')
expect(next, 'the checkout rides on, whole').toBe(checkout)
await expect(page.getByRole('status').filter({ hasText: /^\d+% off your first month of Pro$/ }), 'the offer, from commerce').toBeVisible({ timeout: 30_000 })
await expect(page.locator('#hero-title')).toHaveText('Create your account')
await expect(page.locator('[data-slot="hero-demo"]'), 'no demo').toHaveCount(0)
const card = page.getByRole('region', { name: /^(Log in|Sign up)$/ })
const google = card.getByRole('button', { name: /Continue with Google$/ })
await expect
.poll(() => google.evaluate((el) => [Math.round(el.getBoundingClientRect().height), getComputedStyle(el).fontSize]), { message: 'pay’s 56px at 16px' })
.toEqual([56, '16px'])
await expect.poll(() => page.evaluate(() => sessionStorage.getItem('signin.destination')), { message: 'written down for the callback' }).toBe(checkout)
expect(await touch(page)).toEqual(TAGS)
// 4. A provider: one hop to IAM's authorize, asked as an account to make.
await google.click()
await expect.poll(() => authorize.length, { timeout: 30_000 }).toBe(1)
const q = authorize[0].searchParams
expect(q.get('client_id')).toBe('hanzo-app')
expect(q.get('redirect_uri')).toBe(`${site.origin}/auth/callback`)
expect(q.get('provider')).toBe('provider-google')
expect(q.get('signup')).toBe('true')
expect(q.get('code_challenge_method')).toBe('S256')
expect(q.get('state'), 'the state the callback is bound to').toBeTruthy()
expect(q.get('hz_ft') ?? '', 'the campaign rides the hop as the first touch').toContain('again-welcome')
})
}
test('one tap from the checkout’s sign-up to an account that exists, and its provider is a sign-in', async ({ page, context, baseURL }) => {
const site = new URL(baseURL ?? 'https://hanzo.ai')
const authorize: URL[] = []
await context.route((u) => u.origin === 'https://hanzo.id' || u.pathname === '/v1/iam/oauth/authorize', (route) => {
const url = new URL(route.request().url())
if (url.pathname === '/v1/iam/oauth/authorize') authorize.push(url)
return route.fulfill({ status: 200, contentType: 'text/html', body: '<title>authorize</title>' })
})
const checkout = '/pay/confirm/card?plan=dev&quantity=1&coupon=50OFFFOUNDER'
await page.goto(`/login?next=${encodeURIComponent(checkout)}`)
await page.waitForURL((u) => u.pathname === '/signup')
await page.getByRole('button', { name: 'I already have an account' }).click({ timeout: 30_000 })
await expect(page.locator('#hero-title')).toHaveText('Sign in')
expect(new URL(page.url()).searchParams.get('next')).toBe(checkout)
await page.getByRole('region', { name: /^(Log in|Sign up)$/ }).getByRole('button', { name: /Continue with GitHub$/ }).click()
await expect.poll(() => authorize.length, { timeout: 30_000 }).toBe(1)
expect(authorize[0].searchParams.get('provider')).toBe('provider-github')
expect(authorize[0].searchParams.get('signup'), 'a sign-in asks for no account').toBeNull()
expect(authorize[0].searchParams.get('redirect_uri')).toBe(`${site.origin}/auth/callback`)
})