mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
120 lines
7.7 KiB
TypeScript
120 lines
7.7 KiB
TypeScript
// 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`)
|
||
})
|