Files
zeekayandHanzo Dev f047d86853 The composer gates read any Message placeholder
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]>
2026-09-08 18:56:14 -07:00

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)
})
})