Files
2026-10-26 01:35:21 -07:00

411 lines
18 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, request, type APIRequestContext } from '@playwright/test'
import { credential, signedIn } from './credential'
/**
* THE BOTS ROOM, AGAINST THE PLATFORM.
*
* There is no `page.route` in this file and there will not be one. Every
* assertion below is answered by api.hanzo.ai with the caller's own IAM bearer,
* because a Bots spec that passes against a fixture proves the fixture.
*
* WHAT A SKIP MEANS HERE. Each capability names the route that would carry it
* and asks the deployment whether that route answers. A capability the platform
* cannot do today is SKIPPED with the status it gave, so the report says
* "the route answered 503" rather than "the test passed". A skip is a finding.
*
* THE CREDENTIAL. `credential()` reads a real token this machine holds at
* /tmp/hz.tok, or $HZ_TOKEN. Never a literal in this file. Seeding it into
* localStorage is how the room is signed in — the token is real, so the reads
* it makes are real; only the redirect through hanzo.id is skipped.
*
* BASE_URL=http://localhost:5173 npx playwright test --project=chromium e2e/bots.spec.ts
*/
const API = 'https://api.hanzo.ai'
/** A name no other run holds, so a leftover cannot make a later run pass. */
const NAME = `e2e-bot-${Math.random().toString(36).slice(2, 8)}`
let token: string | null = null
let api: APIRequestContext | null = null
/** What each capability rests on, and what the deployment answered when asked. */
const PROBE = {
bots: '/v1/agent',
sessions: '/v1/agent/sessions',
machines: '/v1/agent/targets',
sandbox: '/v1/sandbox',
mcp: '/v1/tools/mcp/servers',
connectors: '/v1/connection',
channels: '/v1/channels',
secrets: '/v1/kms/secrets',
toolcall: '/v1/tools/call',
coding: '/v1/agent/coding',
projects: '/v1/projects',
} as const
const up: Record<string, number> = {}
test.beforeAll(async () => {
token = credential() ?? process.env.HZ_TOKEN ?? null
if (!token) return
api = await request.newContext({
baseURL: API,
extraHTTPHeaders: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
})
for (const [k, path] of Object.entries(PROBE)) {
// A route that only accepts POST answers 405 to a GET, which still proves it
// is mounted; 404 is the deployment saying the module is not there at all.
up[k] = (await api.get(path)).status()
}
console.log('routes →', JSON.stringify(up))
})
test.afterAll(async () => {
if (api) {
await api.delete(`/v1/agent/${NAME}`).catch(() => {})
await api.dispose()
}
})
/** Refuses to run a check whose route the deployment does not serve. */
function needs(key: keyof typeof PROBE, ok: number[] = [200]) {
test.skip(!token, 'no IAM token on this machine — put one in /tmp/hz.tok or $HZ_TOKEN')
test.skip(
!ok.includes(up[key]),
`${PROBE[key]} answered ${up[key]} on this deployment — the capability has no route to rest on`,
)
}
/** Opens /bots signed in with the real bearer. */
async function room(page: import('@playwright/test').Page) {
await page.addInitScript(signedIn(token as string), token as string)
await page.goto('/bots')
await expect(page.getByRole('button', { name: 'New bot' })).toBeVisible({ timeout: 20_000 })
}
/**
* Asks the fleet for a computer, bounded.
*
* MEASURED 2026-09-04: a `dev` lease is refused in under a second, and a
* `desktop` lease does not answer at all — 75s with no response. An unbounded
* wait would report that as this spec timing out, which blames the wrong thing,
* so a silence is caught here and named as a silence.
*/
async function lease(klass: 'dev' | 'desktop') {
try {
return await api!.post('/v1/sandbox/lease', {
data: { class: klass, project: `bot-${NAME}` },
timeout: 20_000,
})
} catch {
return null
}
}
/** Puts one bot in the org so a check has something of its own to act on. */
async function seed() {
const made = await api!.post('/v1/agent', {
data: {
name: NAME,
model: 'enso-auto',
description: 'end-to-end check',
instructions: 'Answer in one word.',
executionMode: 'one-shot',
},
})
expect([201, 409], await made.text()).toContain(made.status())
}
// ── 1. Create ───────────────────────────────────────────────────────────────
test('creates a bot, through the room', async ({ page }) => {
needs('bots')
await room(page)
await page.getByRole('button', { name: 'New bot' }).click()
await page.getByLabel('Bot name').fill(NAME)
await page.getByLabel('Description').fill('end-to-end check')
await page.getByLabel('Standing instruction').fill('Answer in one word.')
await page.getByRole('button', { name: 'Hire' }).click()
// The roster is the platform's answer, not the form's — it re-reads /v1/agent.
await expect(page.getByRole('button', { name: new RegExp(NAME) })).toBeVisible({ timeout: 20_000 })
const back = await api!.get(`/v1/agent/${NAME}`)
expect(back.status()).toBe(200)
expect((await back.json()).name).toBe(NAME)
})
// ── 2. Launch ───────────────────────────────────────────────────────────────
test('launches it: the runtime panel reads the platform, not a fixture', async ({ page }) => {
needs('bots')
await seed()
await room(page)
await page.getByRole('button', { name: new RegExp(NAME) }).click()
await page.getByRole('button', { name: 'Runtime' }).click()
const panel = page.getByRole('complementary', { name: `${NAME} runtime` })
await expect(panel).toBeVisible()
// Its own disk, named from its own name — the scope that makes it this Bot's.
await expect(panel.getByText(`bot-${NAME}`)).toBeVisible()
await expect(panel.getByRole('heading', { name: 'Computer' })).toBeVisible()
await expect(panel.getByRole('heading', { name: 'Tools' })).toBeVisible()
})
// ── 3. On-demand task ───────────────────────────────────────────────────────
test('runs an on-demand task', async () => {
needs('bots')
await seed()
const ran = await api!.post(`/v1/agent/${NAME}/run`, { data: { input: 'say ok' } })
const body = await ran.text()
// MEASURED 2026-09-04: this answers 503 {"code":"balance_unavailable"} — the
// gateway will not meter a run. The route exists; the platform refuses it.
test.skip(ran.status() !== 200, `POST /v1/agent/{ref}/run answered ${ran.status()}: ${body.slice(0, 120)}`)
expect(JSON.parse(body).status).toBe('ok')
const history = await api!.get(`/v1/agent/${NAME}/runs`)
expect((await history.json()).runs.length).toBeGreaterThan(0)
})
// ── 4. 24/7 ─────────────────────────────────────────────────────────────────
test('enables and disables 24/7', async ({ page }) => {
needs('bots')
await seed()
await room(page)
await page.getByRole('button', { name: new RegExp(NAME) }).click()
const toggle = page.getByRole('button', { name: '24/7' })
await expect(toggle).toHaveAttribute('aria-pressed', 'false')
await toggle.click()
await expect(toggle).toHaveAttribute('aria-pressed', 'true')
expect((await (await api!.get(`/v1/agent/${NAME}`)).json()).executionMode).toBe('long-running')
await toggle.click()
await expect(toggle).toHaveAttribute('aria-pressed', 'false')
expect((await (await api!.get(`/v1/agent/${NAME}`)).json()).executionMode).toBe('one-shot')
})
// ── 5. Sandboxed code ───────────────────────────────────────────────────────
test('executes sandboxed code on the bot’s own disk', async () => {
needs('sandbox')
const leased = await lease('dev')
// MEASURED 2026-09-04: 503 "admin credentials: DO_API_TOKEN not configured" —
// the fleet holds no cloud credential, so no sandbox can start.
test.skip(
!leased || ![200, 201].includes(leased.status()),
leased
? `POST /v1/sandbox/lease answered ${leased.status()}: ${(await leased.text()).slice(0, 140)}`
: 'POST /v1/sandbox/lease did not answer within 20s',
)
const { id } = await leased!.json()
const ran = await api!.post('/v1/sandbox/run', { data: { id, command: 'echo hanzo-bots' } })
expect(ran.status()).toBe(200)
expect((await ran.json()).stdout).toContain('hanzo-bots')
await api!.post('/v1/sandbox/end', { data: { id } })
})
// ── 6. Hanzo Dev ────────────────────────────────────────────────────────────
test('uses Hanzo Dev on a repo', async () => {
// The coding run is POST /v1/agent/coding. MEASURED 2026-09-04: 404 — the
// whole agent module was unmounted; nothing under /v1/agent answered.
needs('coding', [200, 405])
const started = await api!.post('/v1/agent/coding', { data: { repo: 'hanzoai/hanzo.ai', prompt: 'list the routes' } })
expect([200, 202]).toContain(started.status())
})
// ── 7. Repository edit ──────────────────────────────────────────────────────
test('edits a repository', async () => {
// An edit is a coding run that lands commits; the session it produces is read
// back through /v1/agent/builds/{org}/{project}. Both live in the agent module.
needs('coding', [200, 405])
const repos = await api!.get('/v1/provider/github/repos')
test.skip(repos.status() !== 200, `GET /v1/provider/github/repos answered ${repos.status()} — no repo to edit`)
expect(repos.status()).toBe(200)
})
// ── 8. Hanzo CLI ────────────────────────────────────────────────────────────
test('invokes the Hanzo CLI through a registered machine', async () => {
// The CLI reaches a Bot by claiming work for a machine: POST
// /v1/agent/targets/{id}/key mints the key `hanzo code --serve` presents.
needs('machines')
const targets = await (await api!.get('/v1/agent/targets')).json()
test.skip(
(targets.targets ?? []).length === 0,
'no machine is registered to this org — POST /v1/agent/targets/{id}/key has no target to mint for',
)
const key = await api!.post(`/v1/agent/targets/${targets.targets[0].id}/key`)
expect(key.status()).toBe(200)
expect((await key.json()).key).toBeTruthy()
})
// ── 9. Cloud authentication ─────────────────────────────────────────────────
test('authenticates to Hanzo Cloud', async ({ page }) => {
test.skip(!token, 'no IAM token on this machine')
// The bearer is IAM's and the platform accepts it: a signed-out room says so
// in words, and this one does not.
const who = await api!.get('/v1/iam/account')
expect(who.status()).toBe(200)
await room(page)
await expect(page.getByText('Sign in to see your bots.')).toHaveCount(0)
})
// ── 10. Cloud resources ─────────────────────────────────────────────────────
test('queries cloud resources with the same identity', async () => {
needs('projects')
// MEASURED 2026-09-04: `GET /v1/projects` answers the module's own capability
// descriptor rather than the org's projects, so the resources are read where
// they actually are — the deployed sites and the object store.
const sites = await api!.get('/v1/projects/sites')
expect(sites.status()).toBe(200)
expect(Array.isArray(await sites.json())).toBe(true)
const buckets = await api!.get('/v1/s3/buckets')
expect(buckets.status()).toBe(200)
expect(await buckets.json()).toHaveProperty('buckets')
})
// ── 11/12. Browser and computer use ─────────────────────────────────────────
test('launches a browser session the bot can drive', async () => {
needs('sandbox')
// A browser session IS a desktop sandbox: the screen is opened through
// POST /v1/sandbox/{id}/screen/ticket and drawn from the URL it mints.
// MEASURED 2026-09-04: a desktop lease never answers.
const leased = await lease('desktop')
test.skip(
!leased || ![200, 201].includes(leased.status()),
leased
? `POST /v1/sandbox/lease (desktop) answered ${leased.status()}: ${(await leased.text()).slice(0, 140)}`
: 'POST /v1/sandbox/lease (desktop) did not answer within 20s',
)
const { id } = await leased!.json()
const grant = await api!.post(`/v1/sandbox/${id}/screen/ticket`)
expect(grant.status()).toBe(201)
expect((await grant.json()).url).toContain('http')
await api!.post('/v1/sandbox/end', { data: { id } })
})
test('creates a computer-use session with a real terminal', async () => {
needs('sandbox')
const leased = await lease('dev')
test.skip(
!leased || ![200, 201].includes(leased.status()),
leased
? `POST /v1/sandbox/lease answered ${leased.status()}: ${(await leased.text()).slice(0, 140)}`
: 'POST /v1/sandbox/lease did not answer within 20s',
)
const { id } = await leased!.json()
const grant = await api!.post(`/v1/sandbox/${id}/terminal/ticket`)
expect(grant.status()).toBe(201)
expect((await grant.json()).url).toContain('http')
await api!.post('/v1/sandbox/end', { data: { id } })
})
// ── 13. Connectors ──────────────────────────────────────────────────────────
test('shows connectors as the account’s, because no route binds one to a bot', async ({ page }) => {
needs('bots')
needs('connectors')
await seed()
await room(page)
await page.getByRole('button', { name: new RegExp(NAME) }).click()
await page.getByRole('button', { name: 'Runtime' }).click()
// The contract has POST /v1/connection/{provider}/device to
// CONNECT one, and nothing that scopes a connector to an agent. So the panel
// states the boundary instead of drawing a switch that would do nothing.
const panel = page.getByRole('complementary', { name: `${NAME} runtime` })
await expect(panel.getByRole('heading', { name: 'Account-wide' })).toBeVisible()
await expect(panel.getByText(/no per-bot binding/i)).toBeVisible()
})
// ── 14. MCP tool ────────────────────────────────────────────────────────────
test('invokes an MCP tool', async () => {
// POST /v1/tools/call runs one of the org's activated tools.
// MEASURED 2026-09-04: 404 — not mounted on this deployment, though the
// published OpenAPI declares it and /v1/tools advertises it.
needs('toolcall', [200, 405])
const servers = await (await api!.get('/v1/tools/mcp/servers')).json()
test.skip((servers.servers ?? []).length === 0, 'this org has registered no MCP server to call')
const called = await api!.post('/v1/tools/call', { data: { name: servers.servers[0].name, arguments: {} } })
expect(called.status()).toBe(200)
})
// ── 15/16. Persistence ──────────────────────────────────────────────────────
test('persists what the bot owns, and it survives a reload', async ({ page }) => {
needs('bots')
await seed()
const saved = await api!.patch(`/v1/agent/${NAME}`, {
data: { schedule: '*/30 * * * *', executionMode: 'long-running', tools: ['read'] },
})
expect(saved.status()).toBe(200)
await room(page)
await page.getByRole('button', { name: new RegExp(NAME) }).click()
await expect(page.getByText('*/30 * * * *')).toBeVisible()
await expect(page.getByRole('button', { name: '24/7' })).toHaveAttribute('aria-pressed', 'true')
await page.reload()
await page.getByRole('button', { name: new RegExp(NAME) }).click()
await expect(page.getByText('*/30 * * * *')).toBeVisible()
await expect(page.getByRole('button', { name: '24/7' })).toHaveAttribute('aria-pressed', 'true')
})
// ── 17. Org isolation ───────────────────────────────────────────────────────
test('keeps a bot inside the org that made it', async () => {
needs('bots')
needs('sessions')
await seed()
// The platform stamps the owner on what it opens — not the client.
const opened = await api!.post('/v1/agent/sessions', { data: { agent: NAME, title: 'isolation check', status: 'running' } })
expect(opened.status()).toBe(201)
const session = await opened.json()
const claims = JSON.parse(Buffer.from(token!.split('.')[1], 'base64url').toString())
expect(session.org).toBe(claims.owner)
expect(session.actor).toBe(`${claims.owner}/${claims.sub}`)
// And a stranger reaches none of it.
const stranger = await request.newContext({ baseURL: API })
expect((await stranger.get(`/v1/agent/${NAME}`)).status()).toBe(403)
await stranger.dispose()
})
// ── 18. Refusal ─────────────────────────────────────────────────────────────
test('refuses an unauthorized action', async () => {
needs('bots')
const none = await request.newContext({ baseURL: API })
expect((await none.get('/v1/agent')).status()).toBe(403)
expect((await none.post('/v1/agent', { data: { name: 'x', model: 'enso-auto' } })).status()).toBe(403)
expect((await none.delete(`/v1/agent/${NAME}`)).status()).toBe(403)
await none.dispose()
const junk = await request.newContext({
baseURL: API,
extraHTTPHeaders: { Authorization: 'Bearer not-a-token' },
})
expect([401, 403]).toContain((await junk.get('/v1/agent')).status())
await junk.dispose()
})