Files
hanzo.ai/e2e/gates/bot.spec.ts
zeekay 001da43c40 The gates read what the product does now where it changed on purpose
- bot: the pricing section is "Included in $100+ Max plans" since e229ebc4.
- crew, together: once something is said the faces under the composer are
  out of the way (@hanzo/rooms b7fbadd); the header is where the room is
  read and where one leaves.
- limits: the composer is Dev's since @hanzo/rooms 0f921c5, so at a laptop
  the fallback banner's message takes two lines beside its actions.
- members: the reader's own row reads as his account, "(you)", with no menu
  (Members.tsx since 5e216f28); the gate asserted the mark was absent.
- leaderboard: /benchmarks also asks the research API for the coding board
  and each mcp.* tool (891bcc56); every address is answered from its
  snapshot, so the console holds no CORS refusal from the real network.
- page-kit: an email body is mail, not a page (lib/publish MAIL, 1b6bcb6a),
  and its hidden preheader is a 1px line on purpose.
- weight: an import written inside a code sample (a template string, the
  server pages that highlight samples since 11265a21) is text, not an import.
- signin: a stranger on /chat is sent to this site's sign-up (e022a96b), so
  the note is read from the origin's sessionStorage across the hop.
2026-10-15 09:42:14 -07:00

147 lines
7.1 KiB
TypeScript

import { expect, test, type Page } from '@playwright/test'
import { existsSync, statSync } from 'node:fs'
import { join, normalize } from 'node:path'
import { OUT, requireExport } from './export'
/**
* HANZO.BOT, AT ITS OWN NAME.
*
* The Sites plane serves this export at hanzo.bot rooted at out/bot.html
* (deploy.yml), so https://hanzo.bot/ here is bot.html and every other path is
* the export's own. hanzo.bot is answered from the files in out/, hanzo.id by
* `./issuer`, and nothing else leaves this machine. The files are handed to the
* browser directly rather than through a local server: the connector wall asks
* for two hundred images at once, and a proxy through a socket dropped some of
* them under load and failed tests that were not about images.
*
* What is measured is what a visitor meets: every section a Bot page owes a
* reader, the controls in the hero leading somewhere that exists, the install
* being the commands the repo gives rather than a download no release carries,
* the layout holding at four widths, and Log in sending the authorize that
* hanzo-app has registered for this host.
*/
const HOST = 'https://hanzo.bot'
test.beforeAll(() => {
requireExport()
})
// A lazy image or a prefetch can still be in flight when a test ends.
test.afterEach(async ({ page }) => page.unrouteAll({ behavior: 'ignoreErrors' }))
/** The file a static host answers a path with: the file, `<path>.html`, or its index. */
function shipped(pathname: string): string | undefined {
const base = join(OUT, normalize(decodeURIComponent(pathname)).replace(/^(\.\.[/\\])+/, ''))
return [base, `${base}.html`, join(base, 'index.html')].find((p) => existsSync(p) && statSync(p).isFile())
}
/** hanzo.bot from the export, rooted where deploy.yml roots it. */
async function hanzoBot(page: Page): Promise<void> {
await page.route('**/*', (route) => route.abort())
await page.route(`${HOST}/**`, (route) => {
const { pathname } = new URL(route.request().url())
const file = shipped(pathname === '/' ? '/bot/details' : pathname)
return file ? route.fulfill({ path: file }) : route.fulfill({ status: 404, body: 'not found' })
})
}
/** The sections a Bot page owes a reader, by the heading each one carries. */
const SECTIONS = [
'A teammate with its own computer',
'One bot per job',
'Bots that hand work to each other',
'Teach it a routine once',
'It remembers what it learns',
'Message your bots where you already are',
// The pricing section's heading since e229ebc4 put resident bots in the Max plans.
'Included in $100+ Max plans',
'On your Mac in two commands',
'Sign it in to the tools you use',
'Questions',
]
test('every section is on the page, under its own heading', async ({ page }) => {
await hanzoBot(page)
await page.goto(`${HOST}/`)
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Teammates you can hand real work to.')
for (const name of SECTIONS) await expect(page.getByRole('heading', { name, exact: true }), name).toBeAttached()
})
test('the hero starts, installs and reaches sales, and nothing offers a download no release carries', async ({ page }) => {
await hanzoBot(page)
await page.setViewportSize({ width: 1366, height: 900 })
await page.goto(`${HOST}/`)
const hero = page.locator('section').first()
await expect(hero.getByRole('link', { name: 'Try Hanzo' })).toHaveAttribute('href', '/login')
await expect(hero.getByRole('link', { name: 'Contact sales' })).toHaveAttribute('href', '/contact-sales')
expect(existsSync(join(OUT, 'contact-sales.html')), 'the sales page ships').toBe(true)
// hanzoai/bot's only release carries no assets: a "Download for Mac" there
// opened a release page with nothing to download.
await expect(page.getByRole('link', { name: /download for mac/i })).toHaveCount(0)
await expect(page.locator('a[href*="github.com/hanzoai/bot/releases"]')).toHaveCount(0)
// Install scrolls to the two commands a Mac runs.
await hero.getByRole('link', { name: 'Install on your Mac' }).click()
const install = page.locator('section#install')
await expect(install).toBeInViewport()
await expect(install.locator('pre')).toHaveText('npm install -g @hanzo/bot\nhanzo-bot onboard --install-daemon')
// The frame's title bar is one row: three lamps and the name beside them.
const bar = install.locator('pre').locator('xpath=preceding-sibling::*[1]')
await expect(bar).toHaveText('terminal')
await expect(bar).toHaveCSS('flex-direction', 'row')
expect((await bar.boundingBox())!.height, 'the lamps stacked down the frame').toBeLessThan(60)
await expect(install.getByRole('link', { name: 'Install guide' })).toHaveAttribute('href', 'https://docs.hanzo.bot/docs/install')
})
test('the OpenClaw move links to the guide itself, not the docs address that answers 404', async ({ page }) => {
await hanzoBot(page)
await page.goto(`${HOST}/`)
const link = page.getByRole('link', { name: /Coming from OpenClaw/ })
await expect(link).toHaveAttribute('href', 'https://github.com/hanzoai/bot/blob/main/docs/install/migrate-from-openclaw.md')
})
// A question is a native <details>: its answer is in the document before
// anything opens it, painted only once it is open, and the click needs no
// script, so it is made the moment the page loads rather than after hydration.
test('a question opens to its answer', async ({ page }) => {
await hanzoBot(page)
await page.goto(`${HOST}/`)
const faq = page.locator('section#faq')
const answer = faq.getByText(/hanzo-bot migrate openclaw shows what would move/)
await expect(answer, 'the answer ships in the document').toBeAttached()
await expect(answer, 'a closed question paints no answer').toBeHidden()
await faq.locator('summary', { hasText: 'I use OpenClaw. Can I move over?' }).click()
await expect(answer).toBeVisible()
})
test('the star count is a count: one star, not "1 stars"', async ({ page }) => {
await hanzoBot(page)
await page.goto(`${HOST}/`)
const pill = page.getByTestId('oss-stars')
await expect(pill).toHaveText(/^(1 star|[\d,]+ stars)$/)
})
for (const width of [390, 820, 1366, 1920])
test(`at ${width}px the page fits the window and the hero's controls do not overlap`, async ({ page }) => {
await hanzoBot(page)
await page.setViewportSize({ width, height: width < 500 ? 844 : 900 })
await page.goto(`${HOST}/`)
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width)
const boxes = await page
.locator('section')
.first()
.locator('a[data-slot="action"]')
.evaluateAll((els) => els.map((e) => e.getBoundingClientRect().toJSON() as DOMRect))
expect(boxes.length).toBe(3)
for (const b of boxes) expect(b.right, 'a hero control runs off the window').toBeLessThanOrEqual(width)
for (let i = 0; i < boxes.length; i++)
for (let j = i + 1; j < boxes.length; j++) {
const [a, c] = [boxes[i], boxes[j]]
const x = Math.min(a.right, c.right) - Math.max(a.left, c.left)
const y = Math.min(a.bottom, c.bottom) - Math.max(a.top, c.top)
expect(x > 0 && y > 0, `hero controls ${i} and ${j} overlap`).toBe(false)
}
})