mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
/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.
179 lines
8.8 KiB
TypeScript
179 lines
8.8 KiB
TypeScript
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¬es=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¬es=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)
|
||
})
|
||
})
|