Files
zeekay 85d366d892 gates: the apex's head and its sign-out, as the page and the bar now are
The landing's description is the one app/page.tsx declares since 25f4d15ef.
The language menu stands beside the account at the bar's end (owner,
2026-10-30), so the sign-out names the account's menu, whose row says Log
out.
2026-10-30 01:39:34 -07:00

200 lines
9.9 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.
import { expect, test, type Page } from '@playwright/test'
import { read, serveExport } from './export'
import { contract, issuer, type Options, type Seen } from './issuer'
/**
* THE APEX IS THE LANDING, FOR EVERYONE.
*
* `/` is hanzo.ai's search result and its share card, so the export has to
* keep answering a crawler with the whole landing and the metadata that
* describes it. And it is where a person chooses where to go — Try Hanzo,
* Join Beta, Pricing — so a reader who is ALREADY signed in, paying or not,
* is answered with the same landing (app/_apex.tsx `members={false}`), and the
* bar's control opens the app at /chat. Nothing about a session or a plan
* chooses for them, and nothing sends them anywhere.
*
* `output: 'export'` decides HOW: there is no server at runtime, so `/` ships
* as one static file with the landing in it, the same file for everybody.
*/
/** What `app/page.tsx` declares. The head has to keep saying exactly this. */
const TITLE = 'Hanzo AI — Smarter, Faster, Cheaper. Pick all three.'
const DESCRIPTION = 'Frontier intelligence up to 200× faster and 400× cheaper. Every model behind one key. Agents sign up, get a key and top up with no human.'
const PAINTS = 'gate.paints'
/**
* Record, per document, whether the browser ever painted it.
*
* Installed before anything else in every document, so the observer is already
* watching when the apex's own boot script runs. `buffered: true` because a
* paint that happened before the observer was constructed is still a paint.
* `requestAnimationFrame` is kept beside it as the weaker of the two signals —
* it fires at a rendering opportunity, which precedes a paint but does not
* prove one — because a run where the frame fired and the paint did not is
* worth being able to read in the failure message.
*/
async function watchPaint(page: Page): Promise<void> {
await page.addInitScript((key: string) => {
const here = location.pathname
const mark = (how: string) => {
try {
const seen = JSON.parse(localStorage.getItem(key) || '{}')
if (!seen[here]) seen[here] = []
if (!seen[here].includes(how)) seen[here].push(how)
localStorage.setItem(key, JSON.stringify(seen))
} catch {
/* a browser that refuses storage cannot be measured this way; the
assertions below read an absent record as an absent paint, so the
store refusing would report a false pass — it does not refuse here */
}
}
try {
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) mark(entry.name)
}).observe({ type: 'paint', buffered: true })
} catch {
/* no paint timing in this engine */
}
requestAnimationFrame(() => mark('frame'))
}, PAINTS)
}
const paints = (page: Page): Promise<Record<string, string[]>> =>
page.evaluate((key: string) => JSON.parse(localStorage.getItem(key) || '{}'), PAINTS)
/**
* A session that can be ENDED — the one thing `signIn` cannot give.
*
* The shared helper writes its tokens from an init script, which runs in every
* document of the tab. That is right for every other spec and fatal for this
* one: `logout()` clears the browser and navigates, and the init script puts
* the tokens straight back before the page it lands on can read them, so the
* reader arrives signed in again and is forwarded into the room. Seeded once,
* with the tab itself remembering that it has been, the sign-out is real.
*/
async function seedOnce(page: Page, options?: Options): Promise<Seen> {
const seen = await issuer(page, options)
await page.addInitScript(() => {
if (sessionStorage.getItem('gate.seeded')) return
sessionStorage.setItem('gate.seeded', '1')
const part = (v: unknown) =>
btoa(JSON.stringify(v)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
const exp = Math.floor(Date.now() / 1000) + 3600
const token = `${part({ alg: 'none', typ: 'JWT' })}.${part({
sub: 'gate',
name: 'gate',
email: '[email protected]',
exp,
orgs: [{ org: 'hanzo', role: 'owner' }],
})}.`
localStorage.setItem('hanzo_iam_access_token', token)
localStorage.setItem('hanzo_iam_id_token', token)
localStorage.setItem('hanzo_iam_expires_at', String(exp * 1000))
})
return seen
}
test.describe('the apex', () => {
let base: string
let stop: () => Promise<void>
test.beforeAll(async () => {
const served = await serveExport()
base = served.url
stop = served.close
})
test.afterAll(async () => stop())
test('still describes itself to a crawler', async () => {
// The bytes, not the browser: a crawler reads the file and runs none of it.
const html = read('index.html')
expect(html, 'the apex title').toContain(`<title>${TITLE}</title>`)
expect(html, 'the apex description').toContain(DESCRIPTION)
expect(html, 'the canonical is the apex itself').toContain(
'<link rel="canonical" href="https://hanzo.ai"/>',
)
expect(html, 'the share card').toContain('property="og:title"')
expect(html, 'the share card names the apex').toContain('content="https://hanzo.ai"')
expect(html, 'the twitter card').toContain('name="twitter:card"')
// And the landing itself is still IN the file. The forwarding is a script;
// if it had become a build-time decision this would be a shell.
expect(html, 'the landing ships in the apex document').toContain('data-slot="masthead"')
expect(html.length, 'the whole landing, not a stub').toBeGreaterThan(100_000)
})
test('answers a stranger with the landing, and stays at /', async ({ page }) => {
await watchPaint(page)
await page.goto(`${base}/`)
await expect(page.locator('header[data-slot="masthead"]')).toBeVisible({ timeout: 20_000 })
expect(new URL(page.url()).pathname, 'nobody signed in, nobody forwarded').toBe('/')
// The landing painted, which is both the point and the proof that the probe
// above can see a paint at all — an assertion that only ever reads "no
// paint" is an assertion that would pass on a broken probe. Polled: the
// observer hears a paint in a task after the frame, so the masthead can be
// laid out and visible a moment before its paint is on the record.
await expect
.poll(async () => (await paints(page))['/'] ?? [], { message: 'the landing paints for a stranger' })
.toContain('first-contentful-paint')
})
test('answers a signed-in reader with the landing too, at /, and its bar opens the app at /chat', async ({ page }) => {
await page.route(
(u) => u.pathname.startsWith('/v1/') && !u.pathname.startsWith('/v1/iam/'),
(route) => {
const path = new URL(route.request().url()).pathname
// A reader who pays: neither a plan nor its absence moves anyone off the landing.
if (path === '/v1/billing/tier') return route.fulfill({ json: { tier: { name: 'dev', displayName: 'Dev' }, balance: { currency: 'usd', prepaidAvailable: 0 } } })
if (path === '/v1/billing/balance') return route.fulfill({ json: { balance: 0, holds: 0, available: 0 } })
return route.request().url().includes('/stream')
? route.fulfill({ status: 200, contentType: 'text/event-stream', body: '' })
: route.fulfill({ json: { sessions: [], data: [] } })
},
)
await seedOnce(page)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(base + '/')
await expect(page.locator('header[data-slot="masthead"]'), 'the landing, signed in').toBeVisible({ timeout: 20_000 })
const open = page.getByRole('banner').getByRole('link', { name: /^Open Hanzo/ })
await expect(open, 'the bar knows the reader').toBeVisible({ timeout: 20_000 })
await expect(open).toHaveAttribute('href', '/chat')
// Held a moment: nothing swaps the landing out or sends the reader away once the session settles.
await page.waitForTimeout(3000)
expect(new URL(page.url()).pathname, 'nobody forwarded').toBe('/')
await expect(page.locator('[data-slot="app-rail"]'), 'none of the app at /').toHaveCount(0)
expect(await page.evaluate(() => document.documentElement.hasAttribute('data-app'))).toBe(false)
await open.click()
await expect.poll(() => new URL(page.url()).pathname).toBe('/chat')
await expect(page.locator('main textarea').first(), 'the app opens in Chat').toBeVisible({ timeout: 20_000 })
})
test('signs out on this site: IAM is told by calls answered here, and the page is /login?from=logout', async ({ page }) => {
// Nothing of hanzo.id is navigated to. The tokens go back and the session
// ends with calls to this origin (lib/auth/client `useSignOut`, @hanzo/iam
// `end`), and the page a sign-out lands on does not ask for the session
// just ended.
const seen = await seedOnce(page, { site: base })
const posted = () => seen.posted.map((p) => p.path)
await page.goto(`${base}/pricing`)
// The account's menu by its name: the language menu stands beside it at the
// bar's end (owner, 2026-10-30), and it opens a menu too.
const me = page.locator('header[data-slot="masthead"]').getByRole('button', { name: /^Account: / })
await expect(me).toBeVisible({ timeout: 20_000 })
await me.click()
const out = page.getByRole('menuitem', { name: 'Log out' })
await expect(out).toBeVisible({ timeout: 20_000 })
await out.click()
await page.waitForURL((u) => u.pathname === '/login' && u.searchParams.get('from') === 'logout', { timeout: 20_000 })
await expect(page.getByRole('region', { name: /^(Log in|Sign up)$/ }).getByLabel('Email'), 'the sign-in, to come straight back').toBeVisible({ timeout: 20_000 })
expect(posted(), 'the tokens handed back and the session ended, here').toEqual(expect.arrayContaining(['/v1/iam/oauth/revoke', '/v1/iam/oauth/logout']))
expect(posted(), 'no ask for the session just ended').not.toContain('/v1/iam/login')
contract(seen)
expect(await page.evaluate(() => localStorage.getItem('hanzo_iam_access_token')), 'the browser is signed out').toBeNull()
})
})