mirror of
https://github.com/hanzoai/works.git
synced 2026-10-30 18:17:58 +00:00
274 lines
11 KiB
TypeScript
274 lines
11 KiB
TypeScript
import { test, expect } from '@playwright/test'
|
|
import { sections, rows, appCount } from '../src/products'
|
|
import { integrations } from '../src/integrations'
|
|
|
|
const products = sections.flatMap((s) => s.items)
|
|
const apps = rows.flatMap((r) => r.items)
|
|
|
|
/** The two grounds the design system paints, as the browser reports them. */
|
|
const DARK = 'rgb(10, 10, 10)' // :root --background
|
|
const LIGHT = 'rgb(247, 247, 247)' // .light --background
|
|
|
|
test.describe('the page a person sees', () => {
|
|
// State the scheme rather than inheriting it. Playwright's Desktop Chrome
|
|
// reports `prefers-color-scheme: light`, so a test that just loads the page
|
|
// measures the runner's preference and calls it the site's default.
|
|
test.use({ colorScheme: 'dark' })
|
|
|
|
test('the hero renders, and it renders on the design system', async ({ page }) => {
|
|
await page.goto('/')
|
|
await expect(page.getByRole('heading', { level: 1 })).toContainText('Run the company')
|
|
|
|
// Not "is the class there" — is the page PAINTED in the system's colours.
|
|
// A missing @hanzo/design import leaves a transparent body and a browser
|
|
// default font, both visible here and nowhere else in the toolchain.
|
|
const painted = await page.evaluate(() => {
|
|
const s = getComputedStyle(document.body)
|
|
return { bg: s.backgroundColor, color: s.color, font: s.fontFamily }
|
|
})
|
|
expect(painted.bg).toBe(DARK)
|
|
expect(painted.color).toBe('rgb(229, 229, 229)') // --foreground
|
|
expect(painted.font).toContain('Geist') // the system's own face, self-hosted
|
|
})
|
|
|
|
test('the buttons are pills, the cards are round', async ({ page }) => {
|
|
await page.goto('/')
|
|
// hanzo.ai sets its calls to action in --radius-full. Measure the computed
|
|
// value, because a square button is the defect this asserts against and a
|
|
// class name cannot show it.
|
|
const btns = page.locator('.btn')
|
|
await expect(btns).toHaveCount(3)
|
|
for (let i = 0; i < 3; i++) {
|
|
const el = btns.nth(i)
|
|
const { radius, height } = await el.evaluate((n) => ({
|
|
radius: parseFloat(getComputedStyle(n).borderTopLeftRadius),
|
|
height: n.getBoundingClientRect().height,
|
|
}))
|
|
// A pill's radius is at least half its height; --radius-full is 9999px.
|
|
expect(radius, 'button is not rounded').toBeGreaterThanOrEqual(height / 2)
|
|
}
|
|
|
|
const cardRadius = await page
|
|
.locator('.card')
|
|
.first()
|
|
.evaluate((n) => parseFloat(getComputedStyle(n).borderTopLeftRadius))
|
|
expect(cardRadius).toBeGreaterThanOrEqual(16) // --radius-2xl is 24px
|
|
})
|
|
|
|
test('the mark is present and has real size', async ({ page }) => {
|
|
await page.goto('/')
|
|
const mark = page.locator('nav svg').first()
|
|
await expect(mark).toBeVisible()
|
|
const box = await mark.boundingBox()
|
|
expect(box!.width).toBeGreaterThan(8)
|
|
expect(box!.height).toBeGreaterThan(8)
|
|
})
|
|
|
|
test('every product is on the page, with the address that serves it', async ({ page }) => {
|
|
await page.goto('/')
|
|
await expect(page.locator('.card')).toHaveCount(products.length)
|
|
for (const p of products) {
|
|
const card = page.locator('.card', { has: page.locator('h3', { hasText: p.name }) }).first()
|
|
await expect(card).toBeVisible()
|
|
await expect(card.locator('.addr')).toHaveText(p.at)
|
|
await expect(card.locator('p')).not.toBeEmpty()
|
|
await expect(card.locator('.cardgo')).toHaveAttribute('href', p.href)
|
|
}
|
|
})
|
|
|
|
test('every business app is listed at its own address', async ({ page }) => {
|
|
await page.goto('/')
|
|
await expect(page.locator('.row li')).toHaveCount(appCount)
|
|
for (const a of apps) {
|
|
const li = page.locator('.row li', { has: page.locator('a', { hasText: a.name }) }).first()
|
|
await expect(li.locator('i')).toHaveText(a.at)
|
|
}
|
|
})
|
|
|
|
test('the works-with bar carries every mark and it scrolls', async ({ page }) => {
|
|
await page.goto('/')
|
|
// Two copies of the track, so the loop has no seam.
|
|
await expect(page.locator('.track li')).toHaveCount(integrations.length * 2)
|
|
for (const m of integrations) {
|
|
await expect(page.locator('.track li span', { hasText: m.name }).first()).toBeVisible()
|
|
}
|
|
// Name AND logo, not one or the other.
|
|
expect(await page.locator('.track li svg').count()).toBe(integrations.length * 2)
|
|
|
|
// The motion is real: the track is wider than its window and animating.
|
|
const moving = await page.locator('.track').evaluate((n) => {
|
|
const s = getComputedStyle(n)
|
|
return {
|
|
name: s.animationName,
|
|
duration: s.animationDuration,
|
|
wider: n.scrollWidth > (n.parentElement?.clientWidth ?? 0),
|
|
}
|
|
})
|
|
expect(moving.name).toBe('slide')
|
|
expect(moving.duration).not.toBe('0s')
|
|
expect(moving.wider).toBeTruthy()
|
|
})
|
|
|
|
test('it renders on a phone', async ({ browser }) => {
|
|
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
|
|
const p = await ctx.newPage()
|
|
await p.goto('/')
|
|
await expect(p.getByRole('heading', { level: 1 })).toBeVisible()
|
|
await expect(p.locator('.card').first()).toBeVisible()
|
|
|
|
// Nothing may spill sideways. A card wider than the window is the usual
|
|
// cause and it is invisible at desktop width.
|
|
const spill = await p.evaluate(
|
|
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
|
)
|
|
expect(spill, 'the page scrolls horizontally at 390px').toBeLessThanOrEqual(1)
|
|
await ctx.close()
|
|
})
|
|
|
|
test('the theme control repaints the page, and it is a cycle', async ({ page }) => {
|
|
await page.goto('/')
|
|
const bg = () => page.evaluate(() => getComputedStyle(document.body).backgroundColor)
|
|
const toggle = page.locator('nav button').first()
|
|
|
|
await expect(toggle).toHaveText('system')
|
|
expect(await bg()).toBe(DARK)
|
|
|
|
await toggle.click()
|
|
await expect(toggle).toHaveText('light')
|
|
await expect.poll(bg).toBe(LIGHT)
|
|
|
|
await toggle.click()
|
|
await expect(toggle).toHaveText('dark')
|
|
await expect.poll(bg).toBe(DARK)
|
|
|
|
await toggle.click()
|
|
await expect(toggle).toHaveText('system')
|
|
await expect.poll(bg).toBe(DARK)
|
|
})
|
|
|
|
test('the choice survives a reload', async ({ page }) => {
|
|
// The no-flash script in <head> and the control in Chrome.tsx read one key.
|
|
// If they ever read two, the page paints one theme and then jumps.
|
|
await page.goto('/')
|
|
await page.locator('nav button').first().click() // -> light
|
|
await expect
|
|
.poll(() => page.evaluate(() => getComputedStyle(document.body).backgroundColor))
|
|
.toBe(LIGHT)
|
|
|
|
await page.reload()
|
|
// Measured on the FIRST paint, before React mounts: the head script's job,
|
|
// and a miss here is the white flash it exists to prevent.
|
|
expect(await page.evaluate(() => getComputedStyle(document.body).backgroundColor)).toBe(LIGHT)
|
|
})
|
|
|
|
test('an unset preference follows the operating system, both ways', async ({ browser }) => {
|
|
for (const [scheme, ground] of [
|
|
['dark', DARK],
|
|
['light', LIGHT],
|
|
] as const) {
|
|
const ctx = await browser.newContext({ colorScheme: scheme })
|
|
const p = await ctx.newPage()
|
|
await p.goto('/')
|
|
expect(await p.evaluate(() => getComputedStyle(document.body).backgroundColor)).toBe(ground)
|
|
await ctx.close()
|
|
}
|
|
})
|
|
|
|
test('sign-in hands off, and this page holds no identity of its own', async ({ page }) => {
|
|
await page.goto('/')
|
|
// A password field, a local form or a second identity is the one thing this
|
|
// site may never grow. Sign in is a link to a surface that authenticates
|
|
// against Hanzo IAM; nothing here mints or verifies anything.
|
|
await expect(page.locator('input[type="password"]')).toHaveCount(0)
|
|
await expect(page.locator('form')).toHaveCount(0)
|
|
|
|
// Every "Sign in" on the page goes to the same place. Two destinations for
|
|
// one word is two answers to "where do I log in".
|
|
const signin = page.getByRole('link', { name: 'Sign in' })
|
|
const n = await signin.count()
|
|
expect(n).toBeGreaterThan(0)
|
|
for (let i = 0; i < n; i++) {
|
|
await expect(signin.nth(i)).toHaveAttribute('href', 'https://console.hanzo.ai')
|
|
}
|
|
})
|
|
})
|
|
|
|
test.describe('the links', () => {
|
|
test('every link is absolute https or a local anchor', async ({ page }) => {
|
|
await page.goto('/')
|
|
const hrefs = await page.locator('a[href]').evaluateAll((els) =>
|
|
els.map((e) => e.getAttribute('href') ?? ''),
|
|
)
|
|
expect(hrefs.length).toBeGreaterThan(20)
|
|
for (const h of hrefs) {
|
|
expect(h, `${h} is neither https nor local`).toMatch(/^(https:\/\/|\/|#)/)
|
|
}
|
|
})
|
|
|
|
test('every host this page sends a reader to answers', async ({ request }) => {
|
|
// A business site with a dead link is a broken promise. This walks the real
|
|
// network, so it fails when a product goes down — which is the point.
|
|
test.slow()
|
|
const urls = [...new Set((await Promise.resolve([...products, ...apps])).map((p) => p.href))]
|
|
const dead: string[] = []
|
|
await Promise.all(
|
|
urls.map(async (u) => {
|
|
for (let tries = 0; tries < 2; tries++) {
|
|
try {
|
|
const r = await request.get(u, { timeout: 25_000, maxRedirects: 5 })
|
|
if (r.status() < 400) return
|
|
} catch {
|
|
/* retry once, then record it */
|
|
}
|
|
}
|
|
dead.push(u)
|
|
}),
|
|
)
|
|
expect(dead, `dead links: ${dead.join(', ')}`).toEqual([])
|
|
})
|
|
})
|
|
|
|
test.describe('the page a machine reads', () => {
|
|
test('llms.txt names every product the page shows', async ({ page }) => {
|
|
// The drift that matters: someone adds a card and forgets the index, so the
|
|
// site tells a person about something it never tells a model.
|
|
const txt = await (await page.request.get('/llms.txt')).text()
|
|
for (const p of [...products, ...apps]) {
|
|
expect(txt, `llms.txt is missing ${p.name}`).toContain(`](${p.href})`)
|
|
}
|
|
expect(txt).toContain('hanzoskills.com/llms.txt')
|
|
})
|
|
|
|
test('robots.txt welcomes the AI crawlers by name', async ({ page }) => {
|
|
const txt = await (await page.request.get('/robots.txt')).text()
|
|
for (const bot of [
|
|
'GPTBot',
|
|
'ClaudeBot',
|
|
'CCBot',
|
|
'Google-Extended',
|
|
'PerplexityBot',
|
|
'Applebot-Extended',
|
|
'meta-externalagent',
|
|
'Bytespider',
|
|
]) {
|
|
expect(txt, `robots.txt does not name ${bot}`).toContain(`User-agent: ${bot}`)
|
|
}
|
|
expect(txt).toContain('Content-Signal: search=yes, ai-input=yes, ai-train=yes')
|
|
// Not one Disallow anywhere. The moment this file grows one, say why here.
|
|
expect(txt).not.toMatch(/^Disallow:\s*\S/m)
|
|
})
|
|
|
|
test('nothing in the export tells a crawler to stay away', async ({ page }) => {
|
|
await page.goto('/')
|
|
const robots = await page.locator('meta[name="robots"]').getAttribute('content')
|
|
expect(robots ?? 'index, follow').not.toContain('noindex')
|
|
})
|
|
|
|
test('a path with no file is a 404, not the homepage', async ({ page }) => {
|
|
// spaMode is off on the serving side for this reason. A fallback would
|
|
// answer 200-with-the-homepage for a missing chunk and hide the break.
|
|
const res = await page.request.get('/nothing-here/')
|
|
expect(res.status()).toBe(404)
|
|
})
|
|
})
|