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

180 lines
9.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect, type Page } from '@playwright/test'
import { mkdirSync } from 'node:fs'
/**
* /dev, LIVE: Hanzo Dev as a section of the app, signed in with a real session.
*
* Run by hand against a host that serves the app (hanzo.ai, and hanzo.app's web
* edition, which draws the same shell):
*
* BASE_URL=https://hanzo.ai E2E_IAM_TOKEN=$(hanzo auth token) \
* pnpm exec playwright test --project=chromium e2e/dev-live.spec.ts
*
* Nothing is mocked: identity, the org, the GitHub repository and branch lists,
* the org's runs and projects are the platform's. Screenshots land in SHOT_DIR.
*/
const TOKEN = process.env.E2E_IAM_TOKEN ?? ''
const SHOTS = process.env.SHOT_DIR ?? 'e2e-shots'
const HOST = new URL(process.env.BASE_URL ?? 'https://hanzo.ai').hostname
const PROJECT = process.env.E2E_PROJECT ?? 'hanzo-platform'
const ORG = process.env.E2E_ORG ?? 'hanzo'
mkdirSync(SHOTS, { recursive: true })
function claims(): { sub: string; exp: number } {
return JSON.parse(Buffer.from((TOKEN.split('.')[1] ?? '').replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString())
}
/** The real token, stored the way the IAM SDK stores it. */
async function signIn(page: Page): Promise<void> {
const { sub, exp } = claims()
await page.addInitScript(
({ token, sub, expMs, org }) => {
localStorage.setItem('hanzo_iam_access_token', token)
localStorage.setItem('hanzo_iam_expires_at', String(expMs))
localStorage.setItem('hanzo:who', sub)
// The organization this browser works in, picked: the account belongs to
// several, and the chooser stands in front of every room until one is.
localStorage.setItem('hanzo_iam_current_org', org)
localStorage.setItem('hanzo:org:picked', '1')
},
{ token: TOKEN, sub, expMs: exp * 1000, org: ORG },
)
}
const composer = (page: Page) => page.getByRole('textbox', { name: 'Describe a task or ask a question' })
const chip = (page: Page, name: string) => page.getByRole('button', { name: new RegExp(`^${name}: `) })
/** A picker's rows — inside its listbox, never the workspace switcher's own <option>s. */
const options = (page: Page) => page.getByRole('listbox').getByRole('option')
async function open(page: Page, path = '/dev', width = 1440): Promise<void> {
await page.setViewportSize({ width, height: width > 500 ? 900 : 844 })
const res = await page.goto(path)
expect(res?.status(), `${path} answers itself`).toBe(200)
expect(new URL(page.url()).hostname, 'no redirect off the host').toBe(HOST)
await expect(page.getByText('Build what’s next.')).toBeVisible({ timeout: 60_000 })
}
test.describe('/dev — Hanzo Dev in the app, live', () => {
test.skip(!TOKEN, 'E2E_IAM_TOKEN (a real hanzo.id access token) is not set')
test('the builder is a section of the app: one sidebar, no strip, the workspace at its top', async ({ page }) => {
await signIn(page)
await open(page)
await expect(page.locator('[data-slot="sidebar"]')).toHaveCount(1)
await expect(page.locator('[data-slot="session-rail"]')).toHaveCount(0)
await expect(page.locator('[data-slot="rail"]'), 'no far-left strip beside the open sidebar').toBeHidden()
const side = page.locator('[data-slot="sidebar"]')
await expect(side.locator('[data-slot="workspace-switcher"] select')).toHaveCount(1)
for (const row of ['New run', 'Artifacts', 'Templates', 'Runs']) await expect(side.getByText(row, { exact: true })).toBeVisible()
await expect(side.getByRole('button', { name: 'Dev', exact: true })).toHaveAttribute('aria-current', 'page')
await expect(composer(page)).toBeVisible()
await page.screenshot({ path: `${SHOTS}/${HOST}-dev-1440.png` })
})
test('the repository and branch pickers read the platform', async ({ page }) => {
await signIn(page)
await open(page)
await chip(page, 'Repository').click()
const search = page.getByPlaceholder('Search repositories…')
await expect(search).toBeFocused()
await expect(options(page).first()).toBeVisible({ timeout: 30_000 })
await page.screenshot({ path: `${SHOTS}/${HOST}-dev-repo-1440.png` })
await search.fill('hanzoai/build')
const build = page.getByRole('listbox').getByRole('option', { name: 'hanzoai/build', exact: true })
await expect(build).toBeVisible({ timeout: 30_000 })
await build.click()
const branch = chip(page, 'Branch')
await expect(branch).toContainText('main')
await branch.click()
await expect(page.getByPlaceholder('Search branches…')).toBeVisible()
await expect(options(page).first()).toContainText('main', { timeout: 30_000 })
await page.screenshot({ path: `${SHOTS}/${HOST}-dev-branch-1440.png` })
})
test('a project opens its workspace in the pane, and the address carries it', async ({ page }) => {
await signIn(page)
await page.setViewportSize({ width: 1440, height: 900 })
const res = await page.goto(`/dev?project=${PROJECT}`)
expect(res?.status()).toBe(200)
await expect(page.getByRole('tablist', { name: 'View' })).toBeVisible({ timeout: 60_000 })
await expect(page.locator('[data-slot="sidebar"]')).toHaveCount(1)
expect(new URL(page.url()).searchParams.get('project')).toBe(PROJECT)
await page.waitForTimeout(4000)
await page.screenshot({ path: `${SHOTS}/${HOST}-dev-project-1440.png` })
})
test('a plan runs in the sandbox, writes nothing, and answers in the transcript', async ({ page, request }) => {
test.setTimeout(10 * 60_000)
await signIn(page)
await open(page)
await chip(page, 'Repository').click()
await page.getByPlaceholder('Search repositories…').fill('hanzoai/build')
await page.getByRole('listbox').getByRole('option', { name: 'hanzoai/build', exact: true }).click()
await page.getByRole('button', { name: 'Mode: Build' }).click()
await page.getByRole('menuitem', { name: /^Plan/ }).or(page.getByRole('menuitemradio', { name: /^Plan/ })).first().click()
await expect(page.getByRole('button', { name: 'Mode: Plan' })).toBeVisible()
await composer(page).fill('Read README.md and say in two sentences what this repository is for. Change nothing.')
const started = page.waitForResponse((r) => r.url().endsWith('/v1/agent/coding') && r.request().method() === 'POST')
await page.keyboard.press('Enter')
const res = await started
expect(res.status()).toBe(202)
expect(res.request().postDataJSON()).toMatchObject({ mode: 'plan', repo: 'hanzoai/build', effort: 'medium' })
const id = ((await res.json()) as { sessionId: string }).sessionId
await expect.poll(() => new URL(page.url()).searchParams.get('run'), { timeout: 30_000 }).toBe(id)
// The plan is the run's final status. Read it off the platform, then find it on the page.
let plan = ''
await expect
.poll(
async () => {
const r = await request.get(`https://api.hanzo.ai/v1/agent/sessions/${id}`, {
headers: { Authorization: `Bearer ${TOKEN}`, 'X-Org-Id': ORG },
})
const d = (await r.json()) as { status?: string; recentEvents?: { kind: string; payload: unknown }[] }
for (const e of d.recentEvents ?? []) {
const b = typeof e.payload === 'string' ? JSON.parse(e.payload) : e.payload
if (e.kind === 'status' && b?.status === 'done' && b?.mode === 'plan') plan = String(b.plan ?? '')
}
return plan ? 'planned' : d.status ?? ''
},
{ timeout: 9 * 60_000, intervals: [5_000] },
)
.toBe('planned')
expect(plan.length, 'the plan has words').toBeGreaterThan(20)
const line = plan.split('\n').map((l) => l.trim()).filter((l) => l.length > 12).pop()!
await expect(page.getByText(line.slice(0, 40), { exact: false }).first()).toBeAttached({ timeout: 60_000 })
await page.screenshot({ path: `${SHOTS}/${HOST}-dev-plan-1440.png` })
})
test('a phone: the builder fits, and nothing scrolls sideways', async ({ page }) => {
await signIn(page)
await open(page, '/dev', 390)
await expect(composer(page)).toBeVisible()
await page.waitForTimeout(1500)
const [scroll, client] = await page.evaluate(() => [document.documentElement.scrollWidth, document.documentElement.clientWidth])
expect(scroll).toBeLessThanOrEqual(client)
await page.screenshot({ path: `${SHOTS}/${HOST}-dev-390.png` })
await page.goto(`/dev?project=${PROJECT}`)
await expect(page.getByRole('tablist', { name: 'View' })).toBeVisible({ timeout: 60_000 })
await page.waitForTimeout(3000)
await page.screenshot({ path: `${SHOTS}/${HOST}-dev-project-390.png` })
})
})
test.describe('the builder’s other addresses answer with hanzo.ai/dev', () => {
const ID = 'sess_0123456789abcdef0123456789abcdef'
for (const [from, to] of [
['https://platform.hanzo.ai/dev', 'https://hanzo.ai/dev'],
[`https://platform.hanzo.ai/dev/${ID}`, `https://hanzo.ai/dev?run=${ID}`],
['https://platform.hanzo.ai/dev/hanzo-platform', 'https://hanzo.ai/dev?project=hanzo-platform'],
['https://console.hanzo.ai/dev', 'https://hanzo.ai/dev'],
['https://dev.hanzo.ai/', 'https://hanzo.ai/dev'],
])
test(`${from} → ${to}`, async ({ request }) => {
const res = await request.get(from, { maxRedirects: 0, headers: { Accept: 'text/html' } })
expect(res.status()).toBe(301)
expect(res.headers()['location']).toBe(to)
})
})