Files
hanzo.ai/e2e/gates/answer.spec.ts
zeekay 958a8bda28 A free chat sends. The Enso Free agreement is not asked again at the composer.
The data-share choice stays the one made at onboarding, and the line under the composer still says a free chat is data-shared. Chat is @hanzo/rooms 0.1.93, which needs iam 0.21.35, build 0.2.79, and tabs compiled with the site.
2026-10-21 02:32:54 -07:00

93 lines
3.9 KiB
TypeScript

import { test, expect } from '@playwright/test'
import { serveExport } from './export'
import { ready } from './chat'
import { signIn } from '../session'
/**
* THE LOOP THE WHOLE PRODUCT IS: ask, and read the answer.
*
* Everything else here measures a room at rest — a canned transcript, an empty
* state, a refusal. Nothing covered the one thing a reader comes for, so a break
* in the send path would have shipped behind a green suite.
*
* The gateway is answered on PATHNAME, not on a URL glob: a local build carries
* `.env.local`'s host and a published one carries api.hanzo.ai, so a glob around
* either passes on one machine and fails on the other.
*/
/** The words every shape below carries, so one assertion covers whichever answered. */
const SAID = 'Here is a **bold** word and a fence:\n\n```ts\nconst n = 1\n```'
/**
* THE ANSWER ARRIVES AS A STREAM, and a mock that hands back a whole JSON body
* is not a slower version of one — it is a different wire the client cannot
* read.
*
* The composer sends `"stream": true` (measured on the request), so the reply
* is `text/event-stream`: `data:` frames each carrying a delta, closed by
* `[DONE]`. Fulfilled as JSON instead, the reader sees a body that never yields
* a token, the turn renders EMPTY, and nothing raises an error — which is
* exactly what a broken send looks like from the outside, and precisely the
* defect this gate exists to catch. It reported it against itself.
*
* One frame per character rather than one for the lot: a client that reads only
* the first frame, or joins frames wrongly, passes a single-frame mock and
* fails a real model.
*
* The two endpoints keep their own shapes — `/v1/agents/chat` answers
* `{ reply, conversationId }` and the hook reads `round?.reply ?? ''`;
* `/v1/chat/completions` is the OpenAI-compatible surface — because which one
* a send takes is the client's business and moves with it. A fresh
* conversation currently takes completions.
*/
const REPLY = { reply: SAID, conversationId: 'gate-answer' }
const STREAM =
[...SAID]
.map((c) => `data: ${JSON.stringify({ choices: [{ index: 0, delta: { content: c } }] })}\n\n`)
.join('') + 'data: [DONE]\n\n'
test('a question is asked and the answer reads back', async ({ page }) => {
await signIn(page)
const site = await serveExport()
try {
await page.route(
(url) => /\/v1\/(chat\/completions|agents\/chat)\b/.test(url.pathname),
(route) =>
new URL(route.request().url()).pathname.endsWith('/chat/completions')
? route.fulfill({ status: 200, contentType: 'text/event-stream', body: STREAM })
: route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(REPLY),
}),
)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${site.url}/chat`)
// THE COMPOSER IS VISIBLE BEFORE IT CAN SEND: a keystroke landing before the
// app is drawn is dropped, which a person typing straight after load would
// also lose. `ready` waits for Chat's send.
await ready(page, 20_000)
const field = page.locator('textarea').first()
await expect(field).toBeVisible({ timeout: 20_000 })
await field.fill('say something')
await field.press('Enter')
const room = page.locator('main')
// The question is in the transcript, which is what says it was sent rather
// than swallowed.
await expect(room).toContainText('say something', { timeout: 20_000 })
// And the answer came back and was READ — rendered as markdown, with the
// control that lifts it.
await expect(room.locator('[data-slot="prose"] strong')).toHaveText('bold', { timeout: 20_000 })
await expect(room.locator('[data-slot="code"]')).toContainText('const n = 1')
await expect(room.getByRole('button', { name: 'Copy this answer' })).toHaveCount(1)
} finally {
await site.close()
}
})