Files
hanzo.ai/e2e/agent-doors.spec.ts
z 8caff6c960 The hero reads A New Pareto Frontier and Super Intelligence for up to 90% less; a copy says Copied prompt
The setup prompt's button holds the width of its longest label, so the swap does
not move it.
2026-10-18 12:22:26 -07:00

314 lines
13 KiB
TypeScript

import { test, expect, type Page } from '@playwright/test'
import { serveExport } from './gates/export'
import { SETUP } from '../components/home/prompts'
/**
* The two ways in, measured on the shipped export.
*
* The fold's menu and the band under it both hand a reader the same platform,
* and both had a way of looking right while doing nothing: a custom scheme with
* no app behind it fails SILENTLY — the browser does not navigate, does not
* error, and fires no event anybody can listen for — and a marquee is a row of
* links that slide out from under the pointer. Neither is visible in a
* screenshot, so both are asserted here.
*
* The addresses are read off the DOM rather than clicked. Clicking a `codex://`
* is untestable by construction: Chromium hands an unknown scheme to the OS and
* tells the page nothing either way, so a test that clicked would pass whether
* the address was right, wrong or missing. Every row is an anchor precisely so
* its address is a fact on the page instead of a side effect of a handler.
*/
const SHOTS = 'tests/screenshots/agents'
const STRIP = '[data-slot="agent-strip"]'
/** @hanzo/appearance keeps the theme under `hanzo.appearance` and puts it on <html>. */
async function paint(page: Page, theme: 'dark' | 'light') {
await page.addInitScript((t) => window.localStorage.setItem('hanzo.appearance', JSON.stringify({ theme: t })), theme)
}
async function openMenu(page: Page) {
// A press before hydration lands on markup with no handler behind it.
await page.waitForLoadState('networkidle')
await page.getByRole('button', { name: 'Open in an agent' }).first().click()
await expect(page.getByRole('link', { name: /Open in Claude Code/ })).toBeVisible()
}
/** The prompt every door carries, whichever way it carries it. */
const asks = (s: string) =>
[
'Read https://hanzo.ai/skill.md',
'hanzo login',
'hanzo template list',
'hanzo template get <slug>',
'hanzo dev',
'project directory',
].every((part) => s.includes(part))
test.describe('the fold hands the prompt over', () => {
test('every row addresses a door that exists', async ({ page }) => {
const server = await serveExport()
try {
await page.goto(`${server.url}/templates`, { waitUntil: 'load' })
await openMenu(page)
const rows = await page
.getByRole('link', { name: /^Open in / })
.evaluateAll((els) =>
els.map((el) => ({
label: el.textContent?.trim() ?? '',
href: el.getAttribute('href') ?? '',
blank: el.getAttribute('target') === '_blank',
})),
)
// A row says where it goes and nothing else. What each door can carry
// still differs, and the announcement below says so to a reader who
// cannot see the app open; the label does not repeat it.
expect(rows.map((r) => r.label)).toEqual([
'Open in Claude Code',
'Open in Codex',
'Open in Cursor',
'Open in Antigravity',
])
const [claude, codex, cursor, anti] = rows
// Claude Code 2.1.268 rejects any host but `open` and reads `q`.
expect(claude.href).toMatch(/^claude-cli:\/\/open\?q=/)
expect(asks(decodeURIComponent(claude.href.split('q=')[1]))).toBe(true)
// Codex accepts settings|skills|automations|threads and reads no query at
// all, so the prompt cannot ride along and the row says so.
expect(codex.href).toBe('codex://threads/new')
// A web address: it resolves for everyone, so it opens in a tab.
expect(cursor.href).toMatch(/^https:\/\/cursor\.com\/link\/prompt\?text=/)
expect(asks(decodeURIComponent(cursor.href.split('text=')[1]))).toBe(true)
expect(cursor.blank).toBe(true)
// The one scheme Antigravity IDE registers, in the form it handles.
expect(anti.href).toBe('antigravity-ide://?windowId=_blank')
// And the two addresses the panel leads out to.
await expect(page.getByRole('link', { name: 'View skill.md' })).toHaveAttribute('href', '/skill.md')
await expect(page.getByRole('link', { name: 'Set up MCP' })).toHaveAttribute(
'href',
'https://docs.hanzo.ai/docs/mcp',
)
} finally {
await server.close()
}
})
test('the clipboard holds the prompt, whichever row was taken', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
const server = await serveExport()
try {
await page.goto(`${server.url}/templates`, { waitUntil: 'load' })
await openMenu(page)
// The address is left alone and its DEFAULT is cancelled instead: handing
// `codex://` to the OS is not what is under test, and an element with the
// href taken off it is no longer the thing that shipped.
await page.evaluate(() => document.addEventListener('click', (e) => e.preventDefault(), true))
await page.getByRole('link', { name: /Open in Codex/ }).click()
expect(asks(await page.evaluate(() => navigator.clipboard.readText()))).toBe(true)
} finally {
await server.close()
}
})
test('the panel opens somewhere a reader can see all of it', async ({ browser }) => {
const server = await serveExport()
try {
for (const [width, height] of [
[1440, 900],
[390, 844],
] as const) {
const ctx = await browser.newContext({ viewport: { width, height } })
const page = await ctx.newPage()
await page.goto(`${server.url}/templates`, { waitUntil: 'load' })
await openMenu(page)
const box = (await page.locator('[data-slot="agents"]').first().boundingBox())!
expect(box.y, `${width}: the top is on screen`).toBeGreaterThanOrEqual(0)
// The composer is fixed to the bottom of every viewport, so clearing
// the window is not the same as being readable.
expect(box.y + box.height, `${width}: the last row clears the composer`).toBeLessThanOrEqual(
height - 80,
)
await ctx.close()
}
} finally {
await server.close()
}
})
test('the keyboard opens it and walks the rows', async ({ page }) => {
const server = await serveExport()
try {
await page.goto(`${server.url}/templates`, { waitUntil: 'load' })
await page.waitForLoadState('networkidle')
await page.getByRole('button', { name: 'Open in an agent' }).first().focus()
await page.keyboard.press('Enter')
// Scoped to the panel: the band below carries the same names, and an
// unscoped match resolves to two elements in strict mode.
const panel = page.locator('[data-slot="agents"]')
for (const name of ['Open in Claude Code', 'Open in Codex', 'Open in Cursor', 'Open in Antigravity', 'View skill\\.md']) {
await page.keyboard.press('Tab')
await expect(panel.getByRole('link', { name: new RegExp(name) })).toBeFocused()
}
} finally {
await server.close()
}
})
})
test.describe('the band scrolls and every mark is a door', () => {
test('the track moves, stops under the pointer, and stands still for a reader who asked', async ({
browser,
}) => {
const server = await serveExport()
try {
for (const motion of ['no-preference', 'reduce'] as const) {
const ctx = await browser.newContext({ reducedMotion: motion })
const page = await ctx.newPage()
// The band lives on /templates, beside the setup prompt it hands over.
await page.goto(`${server.url}/templates`, { waitUntil: 'load' })
// Past hydration, so the element measured is the one that stays.
await page.waitForLoadState('networkidle')
const track = page.locator(`${STRIP} [data-slot="agent-track"]`)
await expect(track).toBeVisible()
// In view, or `page.mouse` below moves to a point off the viewport and
// hovers nothing — which reads exactly like a pause that did not work.
await page.locator(STRIP).scrollIntoViewIfNeeded()
const at = () =>
track.evaluate((el) => new DOMMatrixReadOnly(getComputedStyle(el).transform).m41)
const before = await at()
await page.waitForTimeout(900)
const after = await at()
if (motion === 'reduce') {
expect(after, 'nothing moves for a reader who asked for that').toBe(before)
} else {
expect(after, 'the track translates').not.toBe(before)
// The pointer stops it, because every name in it is a click target.
// Moved by hand, and to the CONTAINER: a hover action waits for a
// stable element and a moving one never is, while a link's own box
// has by now translated out past the container's left edge, so its
// centre is a point over the page and not over the row at all.
const box = (await page.locator(`${STRIP} [data-slot="agent-window"]`).boundingBox())!
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
await page.waitForTimeout(200)
const held = await at()
await page.waitForTimeout(600)
expect(await at(), 'hover pauses it').toBe(held)
}
// Either way the row is reachable by hand: the window around the track
// scrolls — overflow is auto, never hidden.
expect(
await page.locator(`${STRIP} [data-slot="agent-window"]`).evaluate((el) => getComputedStyle(el).overflowX),
).toBe('auto')
await ctx.close()
}
} finally {
await server.close()
}
})
test('every name links a docs page, and the duplicate pass is out of the way', async ({ page }) => {
const server = await serveExport()
try {
await page.goto(`${server.url}/templates`, { waitUntil: 'load' })
const pass = page.locator(`${STRIP} [data-slot="agent-track"] > div`)
const rows = await pass
.first()
.locator('a')
.evaluateAll((els) =>
els.map((el) => ({ name: el.textContent?.trim(), href: el.getAttribute('href') })),
)
expect(rows.length).toBe(13)
for (const r of rows) {
expect(r.href, `${r.name} addresses the docs content tree`).toMatch(
/^https:\/\/docs\.hanzo\.ai\/docs\//,
)
}
// The copies exist for the seam and for nothing else.
for (const n of [1, 2]) {
await expect(pass.nth(n)).toHaveAttribute('aria-hidden', 'true')
expect(await pass.nth(n).locator('a').evaluateAll((els) => els.every((e) => e.tabIndex === -1))).toBe(
true,
)
}
// Keyboard reaches the real ones, and the ring is ink rather than white.
await pass.first().locator('a').first().focus()
const ring = await page.evaluate(() => {
const el = document.activeElement as HTMLElement
return { tag: el.tagName, color: getComputedStyle(el).outlineColor }
})
expect(ring.tag).toBe('A')
expect(ring.color).not.toBe('rgba(0, 0, 0, 0)')
} finally {
await server.close()
}
})
test('the band copies the setup prompt, the same one the fold hands over', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
const server = await serveExport()
try {
await page.goto(`${server.url}/templates`, { waitUntil: 'load' })
const kit = page.locator(`${STRIP} button`).first()
await expect(kit).toHaveText('Copy setup prompt')
await kit.click()
await expect(kit).toHaveText('Copied prompt')
const copied = await page.evaluate(() => navigator.clipboard.readText())
expect(copied).toBe(SETUP)
expect(asks(copied)).toBe(true)
} finally {
await server.close()
}
})
})
test.describe('what it looks like', () => {
for (const theme of ['dark', 'light'] as const) {
for (const [width, height, at] of [
[1440, 900, 'desktop'],
[390, 844, 'phone'],
] as const) {
test(`${at} ${theme}`, async ({ browser }) => {
const server = await serveExport()
const ctx = await browser.newContext({ viewport: { width, height }, reducedMotion: 'reduce' })
const page = await ctx.newPage()
try {
await paint(page, theme)
await page.goto(`${server.url}/templates`, { waitUntil: 'load' })
await page.waitForLoadState('networkidle')
await page.locator(STRIP).scrollIntoViewIfNeeded()
await page.waitForTimeout(400)
await page.locator(STRIP).screenshot({ path: `${SHOTS}/strip-${at}-${theme}.png` })
await page.goto(`${server.url}/templates`, { waitUntil: 'load' })
await openMenu(page)
await page.waitForTimeout(300)
await page.screenshot({ path: `${SHOTS}/menu-${at}-${theme}.png` })
} finally {
await ctx.close()
await server.close()
}
})
}
}
})