mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
267 lines
13 KiB
TypeScript
267 lines
13 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test'
|
||
|
||
/**
|
||
* THE DIRECTORY, AND THE ROOM IT STARTS.
|
||
*
|
||
* The claim under test is the one that cannot be read off the source: a
|
||
* conversation begun from a contact is a conversation like any other. So this
|
||
* signs in for real, starts one from a row, and then goes looking for it in the
|
||
* two places that read the thread store — the chat pane and the sidebar's
|
||
* recents — after a FULL RELOAD, which is what tells a stored thread apart from
|
||
* a value some component is still holding in memory.
|
||
*
|
||
* IT SIGNS IN, rather than seeding a token like `e2e/session.ts`. A seeded
|
||
* session proves the rooms draw; only a real one proves they read, because
|
||
* every list here is org-scoped and the org comes off the bearer.
|
||
*
|
||
* /v1 GOES TO `CLOUD`. The dev server rewrites /v1 to whatever
|
||
* `HANZO_API_ORIGIN` names, decided when it booted — so a shared dev server
|
||
* pointed at a local cloud cannot be re-aimed by a spec. This routes the app's
|
||
* own requests, unchanged but for their origin, at the cloud this run names.
|
||
* The page still asks for `<base>/v1/…`, so nothing about the app's request
|
||
* path is skipped.
|
||
*/
|
||
|
||
const BASE = process.env.BASE_URL || 'http://localhost:5173'
|
||
const CLOUD = process.env.HANZO_API_ORIGIN || 'https://api.hanzo.ai'
|
||
const USER = process.env.IAM_USER
|
||
const PASS = process.env.IAM_PASS
|
||
|
||
/** Send the app's /v1 calls to the cloud this run names, headers and all. */
|
||
async function reach(page: Page): Promise<void> {
|
||
await page.route(`${BASE}/v1/**`, async (route) => {
|
||
const at = new URL(route.request().url())
|
||
const headers = { ...route.request().headers() }
|
||
// A browser's own Origin and Referer name localhost, and the gateway
|
||
// admits an origin by allowlist — so they are dropped rather than
|
||
// forwarded, which is exactly what a server-side rewrite does with them.
|
||
delete headers.origin
|
||
delete headers.referer
|
||
delete headers.host
|
||
await route.fulfill({
|
||
response: await route.fetch({ url: `${CLOUD}${at.pathname}${at.search}`, headers }),
|
||
})
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Sign in through IAM with the credentials this machine holds.
|
||
*
|
||
* TOLERANT OF THE ISSUER BEING RESTARTED, because it is a live service and a
|
||
* 502 from its edge is not a fact about this app. Three attempts, and the third
|
||
* failure is the spec's.
|
||
*/
|
||
async function signIn(page: Page, tries = 3): Promise<void> {
|
||
for (let attempt = 1; ; attempt++) {
|
||
try {
|
||
await page.goto(`${BASE}/chat`, { waitUntil: 'domcontentloaded' })
|
||
const start = page.locator('button:has-text("Sign in"), a:has-text("Sign in"), a:has-text("Log in")').first()
|
||
// The control appears once the shell has hydrated and asked IAM who this
|
||
// is, which on a cold dev server is behind a compile. A pause raced it.
|
||
await start.waitFor({ state: 'visible', timeout: 120_000 })
|
||
await start.click()
|
||
await page.waitForURL(/hanzo\.id/, { timeout: 60_000 })
|
||
const email = page
|
||
.locator('input[type="email"], input[name="username"], input[autocomplete="username"]')
|
||
.first()
|
||
await email.waitFor({ state: 'visible', timeout: 45_000 })
|
||
await email.fill(USER as string)
|
||
await page.locator('input[type="password"]').first().fill(PASS as string)
|
||
// The form's own control, not Enter in the field: the password input does
|
||
// not submit on a keypress here, so the return key filled it and waited.
|
||
await page.getByRole('button', { name: 'Continue', exact: true }).click()
|
||
await page.waitForURL((url) => url.href.startsWith(BASE), { timeout: 90_000 })
|
||
await page.waitForTimeout(5000)
|
||
return
|
||
} catch (e) {
|
||
if (attempt === tries) throw e
|
||
await page.waitForTimeout(10_000)
|
||
}
|
||
}
|
||
}
|
||
|
||
test.describe('Contacts', () => {
|
||
test.skip(!USER || !PASS, 'Signs in for real: set IAM_USER and IAM_PASS.')
|
||
test.setTimeout(420_000)
|
||
|
||
// A room keeps reading after the last assertion — the roster refreshes on its
|
||
// own — and a proxied request still in flight when the test ends fails inside
|
||
// the route callback, which the runner reports as an error belonging to no
|
||
// test. Dropping the routes first is what closes that door.
|
||
test.afterEach(async ({ page }) => {
|
||
await page.unrouteAll({ behavior: 'ignoreErrors' })
|
||
})
|
||
|
||
test('lists the org, searches, sorts, and starts a conversation that lands in the chat store', async ({
|
||
page,
|
||
}) => {
|
||
await reach(page)
|
||
await signIn(page)
|
||
|
||
// The room, not the rail: the sidebar carries a roster and a sort of its
|
||
// own, and every name below would match twice without this.
|
||
const room = page.getByRole('main')
|
||
const rows = room.locator('button[aria-label]')
|
||
|
||
await test.step('the directory draws the org', async () => {
|
||
await page.goto(`${BASE}/contacts`, { waitUntil: 'domcontentloaded' })
|
||
// The room's own heading, and the first thing on screen after the shell
|
||
// hydrates — which on a dev server is behind this page's compile.
|
||
await expect(room.getByRole('heading', { name: 'Contacts' })).toBeVisible({
|
||
timeout: 60_000,
|
||
})
|
||
await expect.poll(() => rows.count(), { timeout: 60_000 }).toBeGreaterThan(0)
|
||
})
|
||
|
||
const first = ((await rows.first().getAttribute('aria-label')) ?? '').trim()
|
||
expect(first.length).toBeGreaterThan(0)
|
||
|
||
await test.step('search narrows the list to what it matches', async () => {
|
||
const before = await rows.count()
|
||
await room.getByLabel('Search contacts').fill(first)
|
||
await expect(room.locator(`button[aria-label="${first}"]`)).toHaveCount(1)
|
||
const after = await rows.count()
|
||
expect(after).toBeLessThanOrEqual(before)
|
||
await room.getByLabel('Search contacts').fill('zzzz-nobody-zzzz')
|
||
await expect(rows).toHaveCount(0)
|
||
await room.getByLabel('Search contacts').fill('')
|
||
await expect.poll(() => rows.count()).toBeGreaterThan(0)
|
||
})
|
||
|
||
await test.step('the two orders are a switch, and only one is on', async () => {
|
||
const alpha = room.getByRole('button', { name: 'A–Z', exact: true })
|
||
const recent = room.getByRole('button', { name: 'Recent', exact: true })
|
||
await expect(alpha).toHaveAttribute('aria-pressed', 'true')
|
||
await recent.click()
|
||
await expect(recent).toHaveAttribute('aria-pressed', 'true')
|
||
await expect(alpha).toHaveAttribute('aria-pressed', 'false')
|
||
await alpha.click()
|
||
await expect(alpha).toHaveAttribute('aria-pressed', 'true')
|
||
})
|
||
|
||
await test.step('the tabs pick one kind', async () => {
|
||
await room.getByRole('button', { name: 'People', exact: true }).click()
|
||
await expect(room.getByRole('button', { name: 'People', exact: true })).toHaveAttribute(
|
||
'aria-pressed',
|
||
'true',
|
||
)
|
||
await expect.poll(() => rows.count()).toBeGreaterThan(0)
|
||
})
|
||
|
||
// WHO the room is with, and what the store will therefore call it.
|
||
const who = ((await rows.first().getAttribute('aria-label')) ?? '').trim()
|
||
const title = `With ${who}`
|
||
|
||
await test.step('a contact goes into a room', async () => {
|
||
await rows.first().click()
|
||
await expect(rows.first()).toHaveAttribute('aria-pressed', 'true')
|
||
await expect(room.getByText('1 selected')).toBeVisible()
|
||
await room.getByRole('button', { name: 'Start conversation' }).click()
|
||
await page.waitForURL(/\/chat\?thread=/, { timeout: 90_000 })
|
||
})
|
||
|
||
const id = new URL(page.url()).searchParams.get('thread') ?? ''
|
||
expect(id).not.toBe('')
|
||
|
||
await test.step('the store has it, under the id the room opened on', async () => {
|
||
const listed = await page.evaluate(async () => {
|
||
const res = await fetch('/v1/agents/chat/conversations', {
|
||
headers: { Authorization: `Bearer ${localStorage.getItem('hanzo_iam_access_token')}` },
|
||
})
|
||
return (await res.json()) as { conversations?: { id: string; title?: string }[] }
|
||
})
|
||
expect(listed.conversations?.some((one) => one.id === id)).toBe(true)
|
||
expect(listed.conversations?.find((one) => one.id === id)?.title).toContain(who)
|
||
})
|
||
|
||
await test.step('a full reload finds it in the room and in the recents', async () => {
|
||
await page.reload({ waitUntil: 'domcontentloaded' })
|
||
await expect(page.getByRole('main').getByText(title)).toBeVisible({ timeout: 60_000 })
|
||
// Drawn OUTSIDE the room as well, which is the sidebar's recents: the
|
||
// list `useThreads` reads, rebuilt from the store by this reload.
|
||
await expect
|
||
.poll(
|
||
async () =>
|
||
(await page.getByText(title, { exact: true }).count()) -
|
||
(await page.getByRole('main').getByText(title, { exact: true }).count()),
|
||
{ timeout: 60_000 },
|
||
)
|
||
.toBeGreaterThan(0)
|
||
})
|
||
|
||
await test.step('a person and an agent open ONE room, and the agent brings its brief', async () => {
|
||
await page.goto(`${BASE}/contacts`, { waitUntil: 'domcontentloaded' })
|
||
await expect.poll(() => rows.count(), { timeout: 60_000 }).toBeGreaterThan(0)
|
||
await room.getByRole('button', { name: 'People', exact: true }).click()
|
||
await room.locator(`button[aria-label="${who}"]`).click()
|
||
await room.getByRole('button', { name: 'Agents', exact: true }).click()
|
||
await expect.poll(() => rows.count(), { timeout: 60_000 }).toBeGreaterThan(0)
|
||
const mate = ((await rows.first().getAttribute('aria-label')) ?? '').trim()
|
||
await rows.first().click()
|
||
// The two were picked under different tabs and are one selection: the
|
||
// filter says what is on screen, never who is in the room.
|
||
await expect(room.getByText('2 selected')).toBeVisible()
|
||
await room.getByRole('button', { name: 'Start conversation' }).click()
|
||
await page.waitForURL(/\/chat\?thread=/, { timeout: 90_000 })
|
||
|
||
const both = new URL(page.url()).searchParams.get('thread') ?? ''
|
||
expect(both).not.toBe('')
|
||
const { thread, agent } = await page.evaluate(
|
||
async ([tid, name]) => {
|
||
const auth = { Authorization: `Bearer ${localStorage.getItem('hanzo_iam_access_token')}` }
|
||
const one = await fetch(`/v1/agents/chat/conversations/${tid}`, { headers: auth })
|
||
const two = await fetch(`/v1/agents/${name}`, { headers: auth })
|
||
return {
|
||
thread: (await one.json()) as { messages?: { role: string; content: string }[] },
|
||
agent: (await two.json()) as { instructions?: string },
|
||
}
|
||
},
|
||
[both, mate],
|
||
)
|
||
expect(thread.messages?.find((m) => m.role === 'user')?.content).toBe(`With ${who} and ${mate}`)
|
||
// The room is recorded as its roster turn; /chat reads each agent's
|
||
// brief fresh when it seats the room.
|
||
expect(thread.messages?.some((m) => m.role === 'system' && m.content === `Agents in this conversation: ${mate}`)).toBe(true)
|
||
void agent
|
||
})
|
||
|
||
await test.step('Recent puts the contact just spoken to first', async () => {
|
||
await page.goto(`${BASE}/contacts`, { waitUntil: 'domcontentloaded' })
|
||
await expect.poll(() => rows.count(), { timeout: 60_000 }).toBeGreaterThan(0)
|
||
await room.getByRole('button', { name: 'Recent', exact: true }).click()
|
||
await expect(rows.first()).toHaveAttribute('aria-label', who)
|
||
})
|
||
|
||
await test.step('a preset installs, and becomes one of the org’s agents', async () => {
|
||
await room.getByRole('button', { name: 'Presets', exact: true }).click()
|
||
const count = await rows.count()
|
||
// A deployment whose whole catalog is installed has nothing to press, and
|
||
// that is a real state rather than a failure.
|
||
test.skip(count === 0, 'Every preset this deployment publishes is installed already.')
|
||
const named = ((await rows.first().getAttribute('aria-label')) ?? '').trim()
|
||
await rows.first().click()
|
||
await expect(room.locator(`button[aria-label="${named}"]`)).toHaveCount(0, {
|
||
timeout: 60_000,
|
||
})
|
||
await room.getByRole('button', { name: 'Agents', exact: true }).click()
|
||
await expect(room.locator(`button[aria-label]`).first()).toBeVisible()
|
||
const made = await page.evaluate(async () => {
|
||
const res = await fetch('/v1/agents', {
|
||
headers: { Authorization: `Bearer ${localStorage.getItem('hanzo_iam_access_token')}` },
|
||
})
|
||
return (await res.json()) as { agents?: { name: string; description?: string }[] }
|
||
})
|
||
const installed = made.agents?.find((one) => one.description === named)
|
||
expect(installed?.name).toBeTruthy()
|
||
// Put the org back: this spec installs to prove the control writes, not
|
||
// to leave an agent nobody asked for.
|
||
await page.evaluate(async (name) => {
|
||
await fetch(`/v1/agents/${name}`, {
|
||
method: 'DELETE',
|
||
headers: { Authorization: `Bearer ${localStorage.getItem('hanzo_iam_access_token')}` },
|
||
})
|
||
}, installed?.name as string)
|
||
})
|
||
})
|
||
})
|