Files
hanzo.ai/e2e/gates/agent.spec.ts
zeekay e981114824 chat: a signed-out reader asks the buying agent, and it routes them to pay
/chat signed out is the buying agent (components/sales/Guest, Agent): the
question the home composer carried in ?q= is asked of cloud's
POST /v1/ai/sales, the reply is drawn as text, and each next step the
server built is a control: a call (the lead is filed first, then the
calendar opens on it), the sign-up, a plan's card checkout, Add funds. A
quiet line says it is a limited preview and how many messages are left; at
the ceiling the sign-up takes the composer's place. A conversation's own
address still signs in, and a signed-in reader keeps the app.

/buying-specialist is the same agent; its keyword script, its typed prices
and its claims to see an account are gone. The model pages' no-key card is
the real lane, POST /v1/chat/public, not a guest key that answers 401.

Measured in the site's names: chat_started, cta_clicked {cta: kind, plan,
agent: sales}, sales_contacted. The floating privacy button is hidden as
room.css meant: the rule names its tag to outrank gui's atomic class.
2026-10-27 01:29:38 -07:00

179 lines
8.8 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 { serveExport } from './export'
import { heard, person, stream } from './heard'
import { answer, signIn } from '../session'
/**
* THE BUYING AGENT ANSWERS A READER WITH NO ACCOUNT.
*
* /chat, signed out, is the buying agent (components/sales/Guest): the question
* the home page's composer carried in `?q=` is asked of `POST /v1/ai/sales` on
* arrival, the answer is drawn as text, and each next step the server built is a
* control. The quiet line says it is a limited preview and how many messages are
* left; at the ceiling the sign-up takes the composer's place. A booking files
* the lead in Hanzo's CRM first and opens the calendar on it. A signed-in reader
* keeps the app. The agent and every address it names are answered here, so what
* is measured is the page; screenshots at a desktop and a phone width.
*/
const TEAM = 'https://hanzo.ai/signup?next=%2Fpay%2Fconfirm%2Fcard%3Finterval%3Dyear%26plan%3Dteam%26quantity%3D8%26returnUrl%3Dhttps%253A%252F%252Fhanzo.ai%252F'
const INTRO = 'https://cal.hanzo.ai/hanzo/intro'
const LEAD = 'lead_0123456789abcdef01234567'
type Reply = { status: number; body: Record<string, unknown> }
const team: Reply = {
status: 200,
body: {
reply: 'Team fits eight people: $20 a member a month billed yearly.\n\nEach seat gets every model and its own usage.',
actions: [{ kind: 'checkout', label: 'Get Team', url: TEAM, plan: 'team', seats: 8, interval: 'year' }],
left: 9,
limit: 10,
resets: 1893456000,
},
}
const call: Reply = {
status: 200,
body: {
reply: 'For your own cloud and a contract, the team will scope it with you on a call.',
actions: [{ kind: 'book', label: 'Book a call', url: INTRO, lead: 'https://api.hanzo.ai/v1/marketing/leads' }],
left: 8,
limit: 10,
resets: 1893456000,
},
}
const held: Reply = {
status: 429,
body: { code: 'guest_limit', signupUrl: 'https://hanzo.ai/signup', resets: 1893456000, agent: 'https://hanzoskills.com/hanzo.md' },
}
/** Answers the agent with `replies` in turn and records what it was asked. */
async function agent(page: Page, replies: Reply[]) {
const asked: { messages: { role: string; content: string }[] }[] = []
await page.route('**/v1/ai/sales', (route) => {
asked.push(route.request().postDataJSON())
const r = replies[Math.min(asked.length - 1, replies.length - 1)]
return route.fulfill({
status: r.status,
contentType: r.status === 200 ? 'application/json' : 'application/problem+json',
body: JSON.stringify(r.body),
})
})
return asked
}
/** The page is no wider than the window. */
async function fits(page: Page) {
const [scroll, client] = await page.evaluate(() => [document.documentElement.scrollWidth, document.documentElement.clientWidth])
expect(scroll, 'no sideways scroll').toBeLessThanOrEqual(client)
}
test.describe('the buying agent', () => {
let base: string
let stop: () => Promise<void>
test.beforeAll(async () => {
const served = await serveExport()
base = served.url
stop = served.close
})
test.afterAll(async () => stop())
for (const [width, height] of [
[1440, 900],
[390, 844],
] as const) {
test(`at ${width}: /chat signed out asks the carried question and offers the plan`, async ({ page }, info) => {
await page.setViewportSize({ width, height })
await person(page)
const seen = await stream(page)
const asked = await agent(page, [team, held])
await page.goto(`${base}/chat?q=${encodeURIComponent('We are 8 people. Which plan fits?')}`)
const agentView = page.locator('[data-agent]')
await expect(agentView).toBeVisible({ timeout: 20_000 })
await expect(page.locator('[data-turn="user"]')).toHaveText('We are 8 people. Which plan fits?')
await expect(page.locator('[data-turn="assistant"]')).toContainText('Team fits eight people', { timeout: 20_000 })
await expect(page.locator('[data-preview]')).toHaveText('Limited free preview · 9 of 10 messages left today')
expect(asked[0].messages, 'the carried question, alone').toEqual([{ role: 'user', content: 'We are 8 people. Which plan fits?' }])
const step = page.getByRole('link', { name: 'Get Team' })
await expect(step).toHaveAttribute('href', TEAM)
await expect(page.getByRole('link', { name: 'Sign up free' })).toBeVisible()
await heard(seen, 'chat_started', (p) => p.source === 'chat' && p.agent === 'sales')
await fits(page)
await page.screenshot({ path: info.outputPath(`agent-${width}.png`), fullPage: false })
// The second question meets the ceiling: the sign-up, and no composer.
await page.getByLabel('Your question').fill('And the API?')
await page.getByRole('button', { name: 'Send' }).click()
await expect(page.locator('[data-held="guest_limit"]')).toBeVisible()
await expect(page.getByLabel('Your question')).toBeDisabled()
await expect(page.locator('[data-preview]')).toHaveText('Limited free preview · 0 of 10 messages left today')
expect(asked[1].messages.map((m) => m.role), 'the conversation so far rides along').toEqual(['user', 'assistant', 'user'])
await page.screenshot({ path: info.outputPath(`agent-held-${width}.png`), fullPage: false })
// The step's own click, with the departure held (as conversion.spec does): a
// step is told to the stream as the site's call-to-action moment.
await page.evaluate(() => document.addEventListener('click', (e) => e.preventDefault(), { capture: true, once: true }))
await step.evaluate((a) => (a as HTMLElement).click())
await heard(seen, 'cta_clicked', (p) => p.cta === 'checkout' && p.plan === 'team' && p.page === 'chat' && p.agent === 'sales')
})
}
test('a booking files the lead and opens the calendar on it', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await person(page)
const seen = await stream(page)
await agent(page, [call])
let filed: Record<string, unknown> | null = null
await page.route('**/v1/marketing/leads', (route) => {
filed = route.request().postDataJSON()
return route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({ lead: LEAD, intro_call: `${INTRO}?email=ada%40analytical.co&name=Ada+Lovelace&notes=Lead+${LEAD}` }),
})
})
const opened: string[] = []
await page.route(`${INTRO}**`, (route) => {
opened.push(route.request().url())
return route.fulfill({ status: 200, contentType: 'text/html', body: '<title>Book a time</title>' })
})
await page.goto(`${base}/buying-specialist`)
await page.getByRole('button', { name: 'We need single sign-on and our own cloud' }).click({ timeout: 20_000 })
await page.getByRole('button', { name: 'Book a call' }).click()
// Heard before the calendar takes the page: the press is the step, the form its second half.
await heard(seen, 'cta_clicked', (p) => p.cta === 'book' && p.page === 'buying-specialist' && p.agent === 'sales')
await page.getByLabel('Your name').fill('Ada Lovelace')
await page.getByLabel('Work email').fill('[email protected]')
await page.getByLabel('Company').fill('Analytical Engines')
await page.getByRole('button', { name: 'Pick a time' }).click()
await expect.poll(() => opened[0], { message: 'the calendar opened on the lead' }).toBe(
`${INTRO}?email=ada%40analytical.co&name=Ada+Lovelace&notes=Lead+${LEAD}`,
)
expect(filed).toMatchObject({ name: 'Ada Lovelace', email: '[email protected]', company: 'Analytical Engines', source: 'hanzo.ai/buying-specialist' })
expect((filed as unknown as { need: string }).need).toBe('We need single sign-on and our own cloud')
})
test('the reply is text: markup the model writes is not drawn', async ({ page }) => {
await person(page)
await stream(page)
await agent(page, [{ status: 200, body: { reply: '<img src=x onerror="window.__owned=1"> <a href="https://evil.example">pay here</a>', actions: [], left: 9, limit: 10 } }])
await page.goto(`${base}/chat?q=hi`)
await expect(page.locator('[data-turn="assistant"]')).toContainText('<a href="https://evil.example">pay here</a>', { timeout: 20_000 })
await expect(page.locator('[data-turn="assistant"] a, [data-turn="assistant"] img')).toHaveCount(0)
expect(await page.evaluate(() => (window as unknown as { __owned?: number }).__owned)).toBeUndefined()
})
test('a signed-in reader at /chat keeps the app', async ({ page }) => {
await signIn(page)
await answer(page, '/v1/models', { data: [{ id: 'free' }] })
const asked = await agent(page, [team])
await page.goto(`${base}/chat`)
await expect(page.locator('main')).toContainText(/What can I help with\?|What’s up/, { timeout: 20_000 })
await expect(page.locator('[data-agent]')).toHaveCount(0)
expect(asked, 'the agent is not asked for a signed-in reader').toHaveLength(0)
})
})