Files
hanzo.ai/e2e/contacts.spec.ts

267 lines
13 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 { 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)
})
})
})