mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
180 lines
9.2 KiB
TypeScript
180 lines
9.2 KiB
TypeScript
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)
|
||
})
|
||
})
|