mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
The post starts on Hanzo; a beta the member asked for is a chip beside it, not the default. More is the library's dropdown, grouped by the betas' groups, in place of a native select. The chip row scrolls sideways on a phone with no scrollbar. The floating "Your Privacy Choices" button is not drawn: the choices are in every marketing footer, in the app's Settings and in the bar the region's rule draws.
309 lines
17 KiB
TypeScript
309 lines
17 KiB
TypeScript
import { expect, test, type Page, type Route } from '@playwright/test'
|
|
import { existsSync, readFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { NOINDEX } from '../../lib/publish'
|
|
import { HANZO, TOPICS, page as share, topic } from '../../lib/hanzo/topics'
|
|
import { OUT, pages, serveExport } from './export'
|
|
import { heard, person, stream } from './heard'
|
|
import { signIn } from '../session'
|
|
|
|
/**
|
|
* SHARING, MEASURED WHERE IT CAN BE.
|
|
*
|
|
* Nobody can see a post go out on another network, and nothing here pretends to.
|
|
* What the cloud can know is what comes back: the member's own link leaves with the
|
|
* channel it was pressed for, the stream hears the press, a visitor who follows the
|
|
* link arrives with the code and the channel kept, and the code is claimed once, when
|
|
* they sign in. The score counts the claim (+1), not the press.
|
|
*/
|
|
|
|
let base: string
|
|
let stop: () => Promise<void>
|
|
test.beforeAll(async () => {
|
|
const served = await serveExport()
|
|
base = served.url
|
|
stop = served.close
|
|
})
|
|
test.afterAll(async () => stop())
|
|
|
|
/** Nothing off this machine answers, popups included. */
|
|
const local = (page: Page) => page.context().route('**/*', (route) => (new URL(route.request().url()).origin === base ? route.continue() : route.abort()))
|
|
|
|
const reply = (route: Route, json: unknown) => {
|
|
const req = route.request()
|
|
const headers = {
|
|
'access-control-allow-origin': req.headers().origin ?? base,
|
|
'access-control-allow-credentials': 'true',
|
|
'access-control-allow-headers': 'content-type, authorization, x-org-id',
|
|
'access-control-allow-methods': 'GET, POST, PUT',
|
|
}
|
|
if (req.method() === 'OPTIONS') return route.fulfill({ status: 204, headers })
|
|
return route.fulfill({ headers, json })
|
|
}
|
|
|
|
const CHANNELS = ['x', 'linkedin', 'facebook', 'bluesky', 'reddit', 'hn', 'email'] as const
|
|
const NAMES: Record<(typeof CHANNELS)[number], string> = { x: 'X', linkedin: 'LinkedIn', facebook: 'Facebook', bluesky: 'Bluesky', reddit: 'Reddit', hn: 'Hacker News', email: 'Email' }
|
|
|
|
test('each channel leaves with the member\'s own link tagged for it, and a press reaches the stream once, named for the channel', async ({ page }) => {
|
|
await local(page)
|
|
await person(page)
|
|
await signIn(page, { name: 'ada', email: '[email protected]' })
|
|
await page.route('**/v1/marketing/beta', (route) => reply(route, { applied: true, program: 'beta', betas: [], points: 0, place: 7, of: 40, actions: [] }))
|
|
await page.route('**/v1/flag/platform', (route) => reply(route, { flags: {} }))
|
|
await page.route('**/v1/referral', (route) => reply(route, { code: 'ada7', link: 'https://hanzo.ai/beta?ref=ada7', counts: { total: 0, signup: 0, qualified: 0 }, referrals: [] }))
|
|
const seen = await stream(page)
|
|
|
|
await page.goto(`${base}/beta?checkout=success&subscription=sub_1`)
|
|
for (const channel of CHANNELS) {
|
|
const link = page.getByRole('link', { name: `Share on ${NAMES[channel]}`, exact: true })
|
|
await expect(link).toBeVisible({ timeout: 20_000 })
|
|
const to = decodeURIComponent((await link.getAttribute('href')) ?? '')
|
|
expect(to, `${channel} carries the member's code`).toContain('ref=ada7')
|
|
expect(to).toContain(`utm_source=${channel}`)
|
|
expect(to).toContain('utm_medium=' + (channel === 'email' ? 'email' : 'social'))
|
|
expect(to).toContain('utm_campaign=beta_referral')
|
|
// No topic picked and no beta asked for: the post is about Hanzo, and so is the link.
|
|
expect(to).toContain(`hanzo.ai${share(HANZO)}?`)
|
|
expect(to).toContain(`utm_content=${HANZO}`)
|
|
// A mail link opens the mail app, not a page; every other channel opens its own.
|
|
const popup = channel === 'email' ? null : page.context().waitForEvent('page')
|
|
await link.click()
|
|
if (popup) await (await popup).close()
|
|
const props = await heard(seen, 'waitlist_shared', (p) => p.method === channel)
|
|
expect(props).toMatchObject({ method: channel, program: 'beta', from: 'beta', topic: HANZO })
|
|
}
|
|
// One press, one event: no channel is heard twice.
|
|
const shared = seen.filter((e) => e.event === 'waitlist_shared').map((e) => e.properties?.method)
|
|
expect(shared).toEqual([...CHANNELS])
|
|
})
|
|
|
|
test('a visitor who follows a shared link keeps the code and the channel, and the code is claimed once they sign in', async ({ page }) => {
|
|
await local(page)
|
|
await person(page)
|
|
const seen = await stream(page)
|
|
const claims: unknown[] = []
|
|
await page.route('**/v1/referral/claim', (route) => {
|
|
if (route.request().method() === 'POST') claims.push(JSON.parse(route.request().postData() || '{}'))
|
|
return reply(route, { recorded: true })
|
|
})
|
|
|
|
await page.goto(`${base}/beta?ref=ada7&utm_source=linkedin&utm_medium=social&utm_campaign=beta_referral`)
|
|
await heard(seen, 'beta_viewed')
|
|
expect(await page.evaluate(() => localStorage.getItem('hz_ref'))).toBe('ada7')
|
|
const kept = await page.evaluate(() => sessionStorage.getItem('hz_campaign') ?? '')
|
|
expect(Object.fromEntries(new URLSearchParams(kept))).toMatchObject({ ref: 'ada7', utm_source: 'linkedin', utm_medium: 'social', utm_campaign: 'beta_referral' })
|
|
expect(claims, 'a stranger claims nothing').toHaveLength(0)
|
|
|
|
// The same browser signs in: the code is claimed, once, and let go.
|
|
await signIn(page, { name: 'bob', email: '[email protected]' })
|
|
await page.route('**/v1/marketing/beta', (route) => reply(route, { applied: false, place: 0, of: 0, points: 0, betas: [], actions: [] }))
|
|
await page.route('**/v1/flag/platform', (route) => reply(route, { flags: {} }))
|
|
await page.goto(`${base}/beta`)
|
|
await expect.poll(() => claims.length, { timeout: 20_000 }).toBe(1)
|
|
expect(claims[0]).toEqual({ code: 'ada7' })
|
|
await expect.poll(() => page.evaluate(() => localStorage.getItem('hz_ref'))).toBeNull()
|
|
})
|
|
|
|
test('the post is one of the set, shuffles to another, can be the member\'s own, and the channels carry it with the card the link shows', async ({ page }) => {
|
|
await local(page)
|
|
await person(page)
|
|
await signIn(page, { name: 'ada', email: '[email protected]' })
|
|
await page.route('**/v1/marketing/beta', (route) => reply(route, { applied: true, program: 'beta', betas: [], points: 0, place: 7, of: 40, actions: [] }))
|
|
await page.route('**/v1/flag/platform', (route) => reply(route, { flags: {} }))
|
|
await page.route('**/v1/referral', (route) => reply(route, { code: 'ada7', link: 'https://hanzo.ai/beta?ref=ada7', counts: { total: 0, signup: 0, qualified: 0 }, referrals: [] }))
|
|
const seen = await stream(page)
|
|
await page.goto(`${base}/beta?checkout=success&subscription=sub_1`)
|
|
|
|
const text = page.locator('[data-slot="beta-post-text"]')
|
|
const x = page.getByRole('link', { name: 'Share on X', exact: true })
|
|
await expect(text).toBeVisible({ timeout: 20_000 })
|
|
const first = (await text.textContent()) ?? ''
|
|
expect(first.length).toBeGreaterThan(20)
|
|
const said = async () => new URL((await x.getAttribute('href')) ?? '').searchParams.get('text')
|
|
await expect.poll(said).toBe(first)
|
|
|
|
await page.getByRole('button', { name: 'Shuffle' }).click()
|
|
await expect(text).not.toHaveText(first)
|
|
const second = (await text.textContent()) ?? ''
|
|
await expect.poll(said).toBe(second)
|
|
|
|
await page.getByRole('button', { name: 'Write your own' }).click()
|
|
const field = page.getByRole('textbox', { name: 'Your post' })
|
|
await field.fill('Ada was here first.')
|
|
await expect.poll(said).toBe('Ada was here first.')
|
|
await page.getByRole('button', { name: 'Done' }).click()
|
|
await expect(text).toHaveText('Ada was here first.')
|
|
|
|
// The card the link unfurls to is drawn, from the file the page's own metadata names.
|
|
const card = page.locator('[data-slot="beta-unfurl"] img')
|
|
await expect(card).toBeVisible()
|
|
expect(await card.evaluate((img: HTMLImageElement) => img.complete && img.naturalWidth)).toBeGreaterThan(0)
|
|
|
|
const popup = page.context().waitForEvent('page')
|
|
await x.click()
|
|
await (await popup).close()
|
|
expect(await heard(seen, 'waitlist_shared', (p) => p.method === 'x')).toMatchObject({ message: 'own' })
|
|
})
|
|
|
|
/** A topic's posts as a reader of English gets them: `{name}` and `{line}` filled in. */
|
|
const posts = (id: string) => {
|
|
const t = topic(id)!
|
|
return t.posts.map((p) => p.text.replaceAll('{name}', t.name).replaceAll('{line}', t.line))
|
|
}
|
|
|
|
/** A built page's head: what a crawler reads, by tag. */
|
|
function head(route: string) {
|
|
const file = pages().find((p) => p.route === route)?.file
|
|
expect(file, `${route} is in the export`).toBeTruthy()
|
|
const html = readFileSync(file!, 'utf8')
|
|
const h = html.slice(0, html.indexOf('</head>'))
|
|
const meta = (key: string) => h.match(new RegExp(`<meta (?:property|name)="${key}" content="([^"]*)"`))?.[1]
|
|
return {
|
|
html,
|
|
title: h.match(/<title>([^<]*)<\/title>/)?.[1]?.replace(/&/g, '&'),
|
|
description: meta('description')?.replace(/&/g, '&'),
|
|
image: meta('og:image'),
|
|
twitter: meta('twitter:image'),
|
|
url: meta('og:url'),
|
|
canonical: [...h.matchAll(/<link rel="canonical" href="([^"]+)"/g)].map((m) => m[1]),
|
|
}
|
|
}
|
|
|
|
test('every topic ships a page of its own: its title and line, its own card, a canonical naming itself, and no index', () => {
|
|
const cards = new Set<string>()
|
|
for (const t of TOPICS) {
|
|
const route = share(t.id)
|
|
const h = head(route)
|
|
expect(h.title, route).toBe(`${t.title} — Hanzo AI`)
|
|
expect(h.description, route).toBe(t.line)
|
|
expect(h.url, route).toBe(`https://hanzo.ai${route}`)
|
|
expect(h.canonical, route).toEqual([`https://hanzo.ai${route}`])
|
|
expect(h.html, `${route} is withheld from the index`).toContain(NOINDEX)
|
|
expect(h.twitter, route).toBe(h.image)
|
|
const image = new URL(h.image!)
|
|
expect(image.origin, route).toBe('https://hanzo.ai')
|
|
expect(existsSync(join(OUT, image.pathname)), `${route}'s card ${image.pathname} is in the export`).toBe(true)
|
|
expect(cards.has(image.pathname), `${route} shares a card with another topic`).toBe(false)
|
|
cards.add(image.pathname)
|
|
// The body is the topic, for a reader without scripts, and the way on.
|
|
expect(h.html).toContain(`<h1`)
|
|
expect(h.html).toContain('href="/beta"')
|
|
}
|
|
})
|
|
|
|
test('a topic changes the link, the card, the title and the post at once; Shuffle keeps the topic, and so does a post of the member\'s own', async ({ page }) => {
|
|
await local(page)
|
|
await person(page)
|
|
await signIn(page, { name: 'ada', email: '[email protected]' })
|
|
// The member asked for CRM: it is a chip beside Hanzo, and the post starts on Hanzo.
|
|
await page.route('**/v1/marketing/beta', (route) => reply(route, { applied: true, program: 'beta', betas: ['crm'], points: 0, place: 7, of: 40, actions: [] }))
|
|
await page.route('**/v1/flag/platform', (route) => reply(route, { flags: {} }))
|
|
await page.route('**/v1/referral', (route) => reply(route, { code: 'ada7', link: 'https://hanzo.ai/beta?ref=ada7', counts: { total: 0, signup: 0, qualified: 0 }, referrals: [] }))
|
|
const seen = await stream(page)
|
|
await page.goto(`${base}/beta?checkout=success&subscription=sub_1`)
|
|
|
|
const pill = page.locator('[data-slot="beta-share"]').getByText(/^https:\/\/hanzo\.ai\/beta\/for\//)
|
|
const card = page.locator('[data-slot="beta-unfurl"] img')
|
|
const title = page.locator('[data-slot="beta-unfurl-title"]')
|
|
const text = page.locator('[data-slot="beta-post-text"]')
|
|
const x = page.getByRole('link', { name: 'Share on X', exact: true })
|
|
const said = async () => new URL((await x.getAttribute('href')) ?? '').searchParams.get('text')
|
|
// The app's rail names its apps too: a chip is the share card's own.
|
|
const chip = (name: string) => page.locator('[data-slot="beta-topics"]').getByRole('button', { name, exact: true })
|
|
|
|
/** The topic shown is `id`: the link, the card its page names, the title, the post and every channel. */
|
|
const shows = async (id: string) => {
|
|
const t = topic(id)!
|
|
await expect(pill).toHaveText(`https://hanzo.ai${share(id)}?ref=ada7`)
|
|
const og = new URL(head(share(id)).image!)
|
|
await expect(card).toHaveAttribute('src', og.pathname + og.search)
|
|
await expect.poll(() => card.evaluate((img: HTMLImageElement) => img.complete && img.naturalWidth)).toBeGreaterThan(0)
|
|
await expect(title).toHaveText(id === HANZO ? 'Get there first — Hanzo AI' : `${t.name} — Hanzo AI`)
|
|
await expect(page.locator('[data-slot="beta-unfurl-line"]')).toHaveText(t.line)
|
|
expect(posts(id)).toContain(await text.textContent())
|
|
await expect.poll(said).toBe(await text.textContent())
|
|
for (const name of ['X', 'LinkedIn', 'Facebook', 'Bluesky', 'Reddit', 'Hacker News', 'Email']) {
|
|
const to = decodeURIComponent((await page.getByRole('link', { name: `Share on ${name}`, exact: true }).getAttribute('href')) ?? '')
|
|
expect(to, name).toContain(`https://hanzo.ai${share(id)}?ref=ada7`)
|
|
expect(to, name).toContain(`utm_content=${id}`)
|
|
}
|
|
}
|
|
|
|
await expect(chip('Hanzo')).toHaveAttribute('aria-pressed', 'true', { timeout: 20_000 })
|
|
await expect(chip('CRM')).toHaveAttribute('aria-pressed', 'false')
|
|
await shows(HANZO)
|
|
await chip('CRM').click()
|
|
await expect(chip('CRM')).toHaveAttribute('aria-pressed', 'true')
|
|
await shows('crm')
|
|
|
|
await chip('Dev').click()
|
|
await expect(chip('Dev')).toHaveAttribute('aria-pressed', 'true')
|
|
await expect(chip('CRM')).toHaveAttribute('aria-pressed', 'false')
|
|
await shows('dev')
|
|
const first = (await text.textContent()) ?? ''
|
|
await page.getByRole('button', { name: 'Shuffle' }).click()
|
|
await expect(text).not.toHaveText(first)
|
|
await shows('dev')
|
|
|
|
// Every other topic is under More, by its group.
|
|
await page.getByRole('button', { name: 'More topics' }).click()
|
|
await page.getByRole('menuitem', { name: 'Koma' }).click()
|
|
await shows('koma')
|
|
await page.getByRole('button', { name: 'Write your own' }).click()
|
|
await page.getByRole('textbox', { name: 'Your post' }).fill('Koma, early.')
|
|
await expect.poll(said).toBe('Koma, early.')
|
|
await expect(pill).toHaveText(`https://hanzo.ai${share('koma')}?ref=ada7`)
|
|
await expect(card).toHaveAttribute('src', new RegExp(`/og/beta-koma\\.png`))
|
|
|
|
const popup = page.context().waitForEvent('page')
|
|
await x.click()
|
|
await (await popup).close()
|
|
expect(await heard(seen, 'waitlist_shared', (p) => p.method === 'x')).toMatchObject({ topic: 'koma', message: 'own' })
|
|
})
|
|
|
|
test('on a phone the topics scroll in their own row and nothing on the page goes sideways', async ({ page }) => {
|
|
await page.setViewportSize({ width: 390, height: 844 })
|
|
await local(page)
|
|
await person(page)
|
|
await signIn(page, { name: 'ada', email: '[email protected]' })
|
|
await page.route('**/v1/marketing/beta', (route) => reply(route, { applied: true, program: 'beta', betas: ['crm', 'koma', 'base'], points: 0, place: 7, of: 40, actions: [] }))
|
|
await page.route('**/v1/flag/platform', (route) => reply(route, { flags: {} }))
|
|
await page.route('**/v1/referral', (route) => reply(route, { code: 'ada7', link: 'https://hanzo.ai/beta?ref=ada7', counts: { total: 0, signup: 0, qualified: 0 }, referrals: [] }))
|
|
await page.goto(`${base}/beta?checkout=success&subscription=sub_1`)
|
|
const row = page.locator('[data-slot="beta-topics"]')
|
|
await expect(row).toBeVisible({ timeout: 20_000 })
|
|
const box = (await row.boundingBox())!
|
|
expect(box.x + box.width).toBeLessThanOrEqual(390)
|
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390)
|
|
// The chips are more than a phone is wide, so their row scrolls instead.
|
|
expect(await row.locator('[role="group"]').evaluate((el) => el.scrollWidth > el.clientWidth)).toBe(true)
|
|
})
|
|
|
|
test('a topic\'s page sends a person on to /beta with the code and the campaign kept, and the code is claimed once they sign in', async ({ page }) => {
|
|
await local(page)
|
|
await person(page)
|
|
const seen = await stream(page)
|
|
const claims: unknown[] = []
|
|
await page.route('**/v1/referral/claim', (route) => {
|
|
if (route.request().method() === 'POST') claims.push(JSON.parse(route.request().postData() || '{}'))
|
|
return reply(route, { recorded: true })
|
|
})
|
|
|
|
const query = 'ref=ada7&utm_source=linkedin&utm_medium=social&utm_campaign=beta_referral&utm_content=dev'
|
|
await page.goto(`${base}${share('dev')}?${query}`)
|
|
await expect(page).toHaveURL(`${base}/beta?${query}`)
|
|
await heard(seen, 'beta_viewed')
|
|
expect(await page.evaluate(() => localStorage.getItem('hz_ref'))).toBe('ada7')
|
|
const kept = await page.evaluate(() => sessionStorage.getItem('hz_campaign') ?? '')
|
|
expect(Object.fromEntries(new URLSearchParams(kept))).toMatchObject({ ref: 'ada7', utm_source: 'linkedin', utm_medium: 'social', utm_campaign: 'beta_referral', utm_content: 'dev' })
|
|
expect(claims, 'a stranger claims nothing').toHaveLength(0)
|
|
|
|
await signIn(page, { name: 'bob', email: '[email protected]' })
|
|
await page.route('**/v1/marketing/beta', (route) => reply(route, { applied: false, place: 0, of: 0, points: 0, betas: [], actions: [] }))
|
|
await page.route('**/v1/flag/platform', (route) => reply(route, { flags: {} }))
|
|
await page.goto(`${base}/beta`)
|
|
await expect.poll(() => claims.length, { timeout: 20_000 }).toBe(1)
|
|
expect(claims[0]).toEqual({ code: 'ada7' })
|
|
await expect.poll(() => page.evaluate(() => localStorage.getItem('hz_ref'))).toBeNull()
|
|
})
|