Files
hanzo.ai/e2e/live-site.spec.ts

156 lines
6.5 KiB
TypeScript

// Live marketing-site E2E + visual capture.
//
// Drives the real, deployed hanzo.ai (or any BASE_URL) with a real browser:
// - renders the core marketing views (home, pricing, zen, open-source, dev, chat)
// - checks the header and footer destinations resolve
// - sweeps header/footer links and fails on any 404/5xx
// - clicks real footer links and confirms each destination renders
//
// Every step writes a PNG to SHOT_DIR so the run doubles as a visual record.
//
// Run against production:
// BASE_URL=https://hanzo.ai \
// SHOT_DIR=/abs/path/to/screenshots \
// npx playwright test e2e/live-site.spec.ts --project=chromium
//
// SHOT_DIR defaults to test-results/live-shots (git-ignored) when unset.
import { test, expect, type Page } from '@playwright/test'
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
const SHOT_DIR = process.env.SHOT_DIR || join('test-results', 'live-shots')
mkdirSync(SHOT_DIR, { recursive: true })
const shot = (name: string) => join(SHOT_DIR, name)
// Scroll the whole page in viewport-sized steps so framer-motion
// `whileInView` sections actually animate in before we screenshot,
// then return to the top for a clean full-page capture.
async function settle(page: Page) {
await page.waitForLoadState('domcontentloaded')
await page.waitForLoadState('networkidle').catch(() => {})
await page.evaluate(async () => {
const step = Math.round(window.innerHeight * 0.85)
for (let y = 0; y < document.body.scrollHeight; y += step) {
window.scrollTo(0, y)
await new Promise((r) => setTimeout(r, 120))
}
window.scrollTo(0, 0)
await new Promise((r) => setTimeout(r, 250))
})
}
async function capture(page: Page, path: string, file: string) {
const resp = await page.goto(path, { waitUntil: 'commit' })
// Static export serves real 200s for prerendered routes.
expect(resp, `no response for ${path}`).toBeTruthy()
expect(resp!.status(), `${path} returned ${resp!.status()}`).toBeLessThan(400)
await settle(page)
const body = await page.locator('body').innerText()
// Signed-out rooms (e.g. /chat auth card) render at ~138 characters.
expect(body.length, `${path} body too short (stub?)`).toBeGreaterThan(100)
await page.screenshot({ path: shot(file), fullPage: true })
return body
}
// Core marketing views the task requires a screenshot of.
const VIEWS: Array<[string, string]> = [
['/', '00-home.png'],
['/pricing', '01-pricing.png'],
['/zen', '02-zen.png'],
['/open-source', '03-open-source.png'],
['/chat', '05-chat.png'],
]
test.describe('hanzo.ai — core marketing views render', () => {
for (const [path, file] of VIEWS) {
test(`renders ${path}`, async ({ page }) => {
test.setTimeout(60000)
const body = await capture(page, path, file)
// Every marketing page has a visible hero heading.
await expect(page.locator('h1, h2').first()).toBeVisible()
// No client-side error boundary / Next 404 text leaked into the view.
expect(body).not.toMatch(/This page could not be found|404\s*\|/i)
})
}
})
// Header + footer destinations. Internal routes must be < 400; we record
// any offender. External links (github/discord/docs) are probed by HEAD.
const INTERNAL_LINKS = [
'/pricing', '/enterprise', '/zen', '/agents', '/chat', '/search',
'/crawl', '/vector', '/cloud', '/iam', '/kms', '/platform', '/console',
'/insights', '/blockchain/chains', '/blockchain/exchange', '/blockchain/wallets',
'/blockchain/pay', '/about', '/careers', '/security', '/press',
'/privacy', '/terms',
]
test.describe('hanzo.ai — link integrity (no 404/5xx)', () => {
test('all header/footer internal routes resolve', async ({ page }) => {
test.setTimeout(120000)
const broken: string[] = []
for (const href of INTERNAL_LINKS) {
const resp = await page.goto(href, { waitUntil: 'commit' }).catch(() => null)
const status = resp?.status() ?? 0
if (!resp || status >= 400) broken.push(`${href} -> ${status || 'no response'}`)
}
expect(broken, `broken links:\n${broken.join('\n')}`).toEqual([])
})
test('all rendered footer links resolve', async ({ page }) => {
test.setTimeout(120000)
await page.goto('/', { waitUntil: 'commit' })
await page.waitForLoadState('networkidle').catch(() => {})
// Same-origin footer links, in EITHER shape. `[href^="/"]` alone found zero
// and read as "the footer renders nothing": the apex footer is HanzoFooter
// from @hanzogui/shell, which writes its own-site links absolutely
// (`https://hanzo.ai/dev`), not as root-relative paths. Selecting on the
// path rather than the spelling is what makes this test about reachability
// instead of about one package's URL style.
const hrefs = await page.locator('footer a[href]').evaluateAll((els) =>
Array.from(
new Set(
els
.map((e) => (e as HTMLAnchorElement).getAttribute('href') ?? '')
.filter((h) => h.startsWith('/') || h.startsWith('https://hanzo.ai/'))
.map((h) => (h.startsWith('/') ? h : h.replace('https://hanzo.ai', ''))),
),
),
)
expect(hrefs.length, 'footer should render many same-origin links').toBeGreaterThan(10)
const broken: string[] = []
for (const href of hrefs) {
const resp = await page.goto(href!, { waitUntil: 'commit' }).catch(() => null)
const status = resp?.status() ?? 0
if (!resp || status >= 400) broken.push(`${href} -> ${status || 'no response'}`)
}
expect(broken, `broken footer links:\n${broken.join('\n')}`).toEqual([])
})
})
test.describe('hanzo.ai — clicking footer links navigates', () => {
// Real clicks (not just goto) on footer anchors; confirm each renders.
const CLICKS: Array<[string, string, string]> = [
['Privacy', '/privacy', '08-link-privacy.png'],
]
for (const [label, expectedPath, file] of CLICKS) {
test(`footer "${label}" -> ${expectedPath}`, async ({ page }) => {
test.setTimeout(60000)
await page.goto('/', { waitUntil: 'commit' })
await page.waitForLoadState('networkidle').catch(() => {})
// Either spelling — see the note on same-origin shapes above.
const link = page
.locator(`footer a[href="${expectedPath}"], footer a[href="https://hanzo.ai${expectedPath}"]`)
.first()
await link.scrollIntoViewIfNeeded()
await expect(link).toBeVisible()
await link.click()
await page.waitForURL(`**${expectedPath}`, { timeout: 15000 })
await settle(page)
await expect(page.locator('h1, h2').first()).toBeVisible()
await page.screenshot({ path: shot(file), fullPage: true })
})
}
})