Files
hanzo.ai/e2e/gates/share.spec.ts
zeekay 70349b34c7 beta: a post about one topic, with that topic's page and card
The share card offers Hanzo, the products and the betas the member asked
for, the rest under More by group. A topic picks the posts Shuffle offers,
the link (/beta/for/<topic>, tagged utm_content) and the card it unfurls
to: that page carries the topic's title, line and card, a canonical naming
itself and the one noindex (lib/publish SHARES), and sends a person on to
/beta with the query kept, so ref and the campaign land as before.

lib/hanzo/topics is the list, its words the site's and BETAS's own.
generate-og draws a card per topic from it, palette-quantized with sharp:
49 cards, 2.9 MB. The cards gate reads every card's rendered words.

Claude-Session: https://claude.ai/code/session_017vrVus6PPBgP5YRzKV1iqW
2026-10-26 12:03:39 -07:00

304 lines
16 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(/&amp;/g, '&'),
description: meta('description')?.replace(/&amp;/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: the post starts there.
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('CRM')).toHaveAttribute('aria-pressed', 'true', { timeout: 20_000 })
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('combobox', { name: 'More topics' }).selectOption('koma')
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()
})