mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
The field says "Message Hanzo AI" at rest and "Message <name>" with a character, so the gates match the verb rather than one channel form. Co-authored-by: Hanzo Dev <[email protected]>
242 lines
10 KiB
TypeScript
242 lines
10 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test'
|
|
import { signIn } from './session'
|
|
|
|
/**
|
|
* THE COLUMN BESIDE A CONVERSATION, AND THE ONE THING NO STATIC CHECK CAN SEE:
|
|
* that it belongs to THIS conversation and survives leaving it.
|
|
*
|
|
* The ask was "switch between workspaces and work on things independently
|
|
* without losing your tabs and history". That is not a render — it is what is
|
|
* still there after a route change and a reload, so every assertion below is
|
|
* made after one or the other. A spec that only opened the pane and read it
|
|
* would pass against a pane that forgets everything the moment you look away.
|
|
*
|
|
* `?agent=` is how a channel is switched here: `open.ts` seeds its selection
|
|
* from the query at module load, so each `goto` is BOTH a different
|
|
* conversation and a fresh document. One navigation therefore tests the switch
|
|
* and the reload at once, which is the pair that matters.
|
|
*
|
|
* These drive a dev server, so they live in the `chromium` project and cannot
|
|
* gate a build — the same rule the other live-surface specs follow.
|
|
*
|
|
* BASE_URL=http://localhost:5188 pnpm exec playwright test --project=chromium e2e/pane.spec.ts
|
|
*/
|
|
|
|
/** An answer carrying the two things an Outputs list is made of. */
|
|
const ANSWER = [
|
|
'Here is the page.',
|
|
'',
|
|
'```chat-home.html',
|
|
'<!doctype html><title>Home</title><h1>Generated page</h1>',
|
|
'```',
|
|
'',
|
|
'```ts',
|
|
'export const add = (a: number, b: number) => a + b',
|
|
'```',
|
|
].join('\n')
|
|
|
|
/**
|
|
* A room that answers, without spending anything.
|
|
*
|
|
* The gateway is the one thing these assertions must not depend on: a real turn
|
|
* costs money, takes seconds, and returns different prose every time, so the
|
|
* Outputs list could never be asserted by name. What is under test is the
|
|
* derivation — turns in, rows out — and that needs a KNOWN turn.
|
|
*/
|
|
async function room(page: Page): Promise<void> {
|
|
await signIn(page)
|
|
await page.route(
|
|
(u) => u.pathname === '/v1/models',
|
|
(r) =>
|
|
r.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ data: [{ id: 'enso-auto', object: 'model' }] }),
|
|
}),
|
|
)
|
|
// TWO ROUNDS AND TWO SHAPES. Signed in, `useChat({ keep: true })` posts the
|
|
// AGENTS round, which answers `{ reply, conversationId }` — not a completion.
|
|
// Answering it with a `choices` array reads as an empty reply and the room
|
|
// draws a blank turn, which is a green-looking way to test nothing.
|
|
await page.route(
|
|
(u) => u.pathname === '/v1/agents/chat',
|
|
(r) =>
|
|
r.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ reply: ANSWER, conversationId: 'thread-under-test' }),
|
|
}),
|
|
)
|
|
await page.route(
|
|
(u) => u.pathname === '/v1/chat/completions',
|
|
(r) =>
|
|
r.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
id: 'c1',
|
|
object: 'chat.completion',
|
|
model: 'enso-auto',
|
|
choices: [{ index: 0, message: { role: 'assistant', content: ANSWER }, finish_reason: 'stop' }],
|
|
}),
|
|
}),
|
|
)
|
|
// A tab must not depend on the live web. Anything the browser opens is
|
|
// answered here, so the strip is asserted rather than the network.
|
|
await page.route('https://hanzo.ai/**', (r) =>
|
|
r.fulfill({ status: 200, contentType: 'text/html', body: '<title>stub</title><p>stub</p>' }),
|
|
)
|
|
}
|
|
|
|
/** Opens the column and waits for the pane to actually be in it. */
|
|
async function beside(page: Page) {
|
|
const toggle = page.getByRole('button', { name: 'Toggle pinned summary' })
|
|
await expect(toggle).toBeVisible({ timeout: 30_000 })
|
|
await toggle.click()
|
|
const outputs = page.locator('[data-card="outputs"]')
|
|
await expect(outputs).toBeVisible({ timeout: 15_000 })
|
|
return page.locator('aside').first()
|
|
}
|
|
|
|
test.describe('the column beside a conversation', () => {
|
|
// THE BUDGET IS PER TEST, NOT PER ASSERTION, and this file spends it three
|
|
// navigations at a time. The suite default is 30s, which is generous for one
|
|
// page and not enough for "open a tab, leave the conversation, come back" —
|
|
// so the last assertion in a passing flow reported "not visible" for an
|
|
// element the failure screenshot plainly showed on screen. That is the
|
|
// budget running out, never the app, and raising it here rather than
|
|
// globally keeps every other spec honest about being slow.
|
|
test.describe.configure({ timeout: 120_000 })
|
|
|
|
test('says what it has rather than inventing rows', async ({ page }) => {
|
|
await room(page)
|
|
await page.setViewportSize({ width: 1440, height: 900 })
|
|
await page.goto('/chat?agent=Empty')
|
|
await page.waitForLoadState('networkidle').catch(() => {})
|
|
|
|
const column = await beside(page)
|
|
|
|
// A conversation nobody has spoken in has produced nothing, and says so.
|
|
// The old pane drew a channel tree here whatever the conversation held.
|
|
await expect(column).toContainText('This conversation has produced nothing yet.')
|
|
await expect(column).toContainText('Nothing has been put into this conversation yet.')
|
|
await expect(page.locator('[data-card="sources"]')).toBeVisible()
|
|
|
|
// Both cards offer the same one way to add to them.
|
|
await expect(page.getByRole('button', { name: 'Add to this conversation' })).toHaveCount(2)
|
|
})
|
|
|
|
test('lists what an answer actually produced, and opens it', async ({ page }) => {
|
|
await room(page)
|
|
await page.setViewportSize({ width: 1440, height: 900 })
|
|
await page.goto('/chat?agent=Maker')
|
|
await page.waitForLoadState('networkidle').catch(() => {})
|
|
const column = await beside(page)
|
|
|
|
const field = page.getByPlaceholder(/Ask anything|Message /).first()
|
|
await field.click()
|
|
// `fill()` never reaches the controlled composer — type the keys.
|
|
await field.pressSequentially('make me a page', { delay: 10 })
|
|
await page.keyboard.press('Enter')
|
|
|
|
// THE NAMES ARE THE FENCES' OWN. A block that named a file is that file; a
|
|
// block that named a language says so rather than pretending to be one.
|
|
await expect(column).toContainText('chat-home.html', { timeout: 20_000 })
|
|
await expect(column).toContainText('ts block')
|
|
|
|
// A block is text in a turn until somebody looks at it, at which point it
|
|
// becomes a document with an address.
|
|
//
|
|
// SCOPED TO THE CARD, because the name is correctly in two places: the row
|
|
// that lists the output, and the fence in the transcript it was read from.
|
|
// That is the derivation working, so the fix is to say which one is meant.
|
|
await column.locator('[data-card="outputs"]').getByRole('button', { name: 'chat-home.html' }).click()
|
|
await expect(page.getByRole('button', { name: 'Close chat-home.html' })).toBeVisible({
|
|
timeout: 10_000,
|
|
})
|
|
await expect(page.locator('aside iframe')).toHaveCount(1)
|
|
|
|
// A DOCUMENT OUT OF A CONVERSATION MUST NOT BE ABLE TO READ THE SESSION.
|
|
//
|
|
// This is the assertion the sandbox exists for, and it cannot be made by
|
|
// reading the attribute: a `blob:` address INHERITS the minting page's
|
|
// origin, so the same markup that is inert here would run as us the moment
|
|
// `allow-same-origin` were added — with `hanzo_iam_access_token` in reach.
|
|
// A model writes these pages. Asserting the attribute would keep passing
|
|
// if the flag were added; asserting the ACCESS cannot.
|
|
const made = page.frames().find((f) => f.url().startsWith('blob:'))
|
|
expect(made, 'the generated page is in a frame of its own').toBeTruthy()
|
|
const reach = await made!.evaluate(() => {
|
|
try {
|
|
return window.localStorage.getItem('hanzo_iam_access_token') ?? 'null'
|
|
} catch {
|
|
return 'refused'
|
|
}
|
|
})
|
|
expect(reach, 'the generated page cannot reach the token').toBe('refused')
|
|
})
|
|
|
|
test('keeps each conversation its own tabs across a switch and a reload', async ({ page }) => {
|
|
await room(page)
|
|
await page.setViewportSize({ width: 1440, height: 900 })
|
|
|
|
// One conversation, one tab.
|
|
await page.goto('/chat?agent=Alpha')
|
|
await page.waitForLoadState('networkidle').catch(() => {})
|
|
await beside(page)
|
|
await page.getByRole('button', { name: 'New tab' }).click()
|
|
// NAMED, NOT PATTERNED. `/^Close /` also matches the frame's own "Close
|
|
// side panel" and "Close sidebar", so it counted four controls and proved
|
|
// nothing about tabs. A new tab opens hanzo.ai, so this is its exact name.
|
|
const tab = page.getByRole('button', { name: 'Close hanzo.ai' })
|
|
await expect(tab).toHaveCount(1, { timeout: 10_000 })
|
|
|
|
// A DIFFERENT CONVERSATION IS A DIFFERENT PANE. Were the pane global, the
|
|
// tab opened above would be standing here — which is the bug this whole
|
|
// store exists to prevent.
|
|
await page.goto('/chat?agent=Beta')
|
|
await page.waitForLoadState('networkidle').catch(() => {})
|
|
await beside(page)
|
|
await expect(page.getByRole('button', { name: 'Close hanzo.ai' })).toHaveCount(0)
|
|
await expect(page.locator('aside')).toContainText('Nothing open here.')
|
|
|
|
// Back again. The tab survived both leaving the conversation and the
|
|
// document that drew it.
|
|
await page.goto('/chat?agent=Alpha')
|
|
await page.waitForLoadState('networkidle').catch(() => {})
|
|
await beside(page)
|
|
await expect(page.getByRole('button', { name: 'Close hanzo.ai' })).toHaveCount(1, {
|
|
timeout: 10_000,
|
|
})
|
|
})
|
|
|
|
test('the pin hides the summary and is remembered', async ({ page }) => {
|
|
await room(page)
|
|
await page.setViewportSize({ width: 1440, height: 900 })
|
|
await page.goto('/chat?agent=Pinner')
|
|
await page.waitForLoadState('networkidle').catch(() => {})
|
|
|
|
const toggle = page.getByRole('button', { name: 'Toggle pinned summary' })
|
|
await expect(toggle).toBeVisible({ timeout: 30_000 })
|
|
|
|
// Shut, the control says the summary is not showing whatever the pin holds.
|
|
await expect(toggle).toHaveAttribute('aria-pressed', 'false')
|
|
await toggle.click()
|
|
await expect(page.locator('[data-card="outputs"]')).toBeVisible({ timeout: 15_000 })
|
|
await expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
|
|
|
// Hidden, the browser keeps the column to itself.
|
|
await toggle.click()
|
|
await expect(page.locator('[data-card="outputs"]')).toHaveCount(0)
|
|
await expect(page.locator('aside')).toContainText('Nothing open here.')
|
|
|
|
// And the choice is this conversation's, kept.
|
|
await page.goto('/chat?agent=Pinner')
|
|
await page.waitForLoadState('networkidle').catch(() => {})
|
|
await expect(page.getByRole('button', { name: 'Toggle pinned summary' })).toBeVisible({
|
|
timeout: 30_000,
|
|
})
|
|
await expect(page.locator('[data-card="outputs"]')).toHaveCount(0)
|
|
})
|
|
})
|