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