mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
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.
93 lines
3.9 KiB
TypeScript
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()
|
|
}
|
|
})
|