mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
Every import, the catalog pin and the docs name @hanzo/app and github.com/hanzoai/app. lib/rooms.ts is lib/app.ts and components/rooms.tsx is components/app.tsx.
196 lines
8.9 KiB
TypeScript
196 lines
8.9 KiB
TypeScript
import { test, expect } from '@playwright/test'
|
||
import { serveExport } from './export'
|
||
import { signIn, answer } from '../session'
|
||
import { talk } from '@hanzo/app/lib/host'
|
||
|
||
/**
|
||
* A CONVERSATION ON /chat RIDES THE ADDRESS.
|
||
*
|
||
* The open thread lives in a module (`components/workspace/open.ts`) that resets
|
||
* on a real page load, so before this a reload dropped the conversation you were
|
||
* reading — the Recents rail could reopen it by click, but the URL held nothing
|
||
* and a conversation could not be linked. A conversation is its own path now,
|
||
* `/chat/<id>` (@hanzo/app lib/host `talk`): the path opens the thread on
|
||
* arrival and the open thread mirrors back out. `/chat?thread=<id>`, the address
|
||
* a conversation had before, moves to its path (app/(app)/chat/_door.tsx `moved`).
|
||
*
|
||
* The subject is `out/` — the bytes that ship — because the defect this guards is
|
||
* in the WIRING: the seed has to reach `Chat`, which reads the transcript through
|
||
* `threads.get`. So the transcript endpoint is answered here with a known two-turn
|
||
* conversation, and the test asserts those turns render — proving the param opened
|
||
* the thread and the pane read it, not merely that a URL was rewritten.
|
||
*
|
||
* NOTHING LEAVES THIS MACHINE. `threads.get` is `GET /v1/agents/chat/conversations/<id>`;
|
||
* it is fulfilled locally, so the transcript is canned and no real conversation is
|
||
* touched. A thread this caller may not read answers empty in production and the
|
||
* pane draws a blank, which is why a shared link degrades rather than leaks — that
|
||
* is the same `null`-on-empty contract the SDK's `get` already enforces.
|
||
*/
|
||
|
||
/** A clearly-fake id: it names no real conversation, only proves the address opens one. */
|
||
const SEED = 'gate-seed-thread'
|
||
|
||
/** The transcript the endpoint answers with. `threads.get` returns null unless
|
||
* `messages.length`, so two real turns are what make it a conversation. */
|
||
const TRANSCRIPT = {
|
||
id: SEED,
|
||
messages: [
|
||
{ id: 'm1', role: 'user', content: 'Ping from a shared link' },
|
||
{ id: 'm2', role: 'assistant', content: 'Pong, restored from the URL' },
|
||
],
|
||
}
|
||
|
||
test.describe('a conversation on /chat rides the address', () => {
|
||
let base: string
|
||
let stop: () => Promise<void>
|
||
|
||
test.beforeAll(async () => {
|
||
const served = await serveExport()
|
||
base = served.url
|
||
stop = served.close
|
||
})
|
||
test.afterAll(async () => stop())
|
||
|
||
test('/chat/<id> opens the conversation: its turns render, the path is kept', async ({ page }) => {
|
||
// A SESSION THAT PAYS, as every room's fixture is (../session): with no
|
||
// token `threads.get` throws before a request leaves the page, and with no
|
||
// plan the organization gate swaps the room for the ask as soon as the real
|
||
// billing read answers, so the turns render or vanish by the API's latency.
|
||
await signIn(page)
|
||
|
||
// The transcript store, answered here. Any host — the export talks to
|
||
// api.hanzo.ai, a local build to localhost — so the glob matches on path.
|
||
await page.route('**/v1/agents/chat/conversations/**', (route) =>
|
||
route.fulfill({
|
||
status: 200,
|
||
contentType: 'application/json',
|
||
body: JSON.stringify(TRANSCRIPT),
|
||
}),
|
||
)
|
||
|
||
await page.goto(`${base}${talk(SEED)}`)
|
||
|
||
// The param opened the thread and the pane read its transcript. Both turns
|
||
// are the proof; the empty state ("What’s up?") is absent for the
|
||
// same reason.
|
||
await expect(page.getByText('Ping from a shared link')).toBeVisible({ timeout: 15000 })
|
||
await expect(page.getByText('Pong, restored from the URL')).toBeVisible()
|
||
|
||
// The address still carries the conversation, so a reload lands here again.
|
||
expect(new URL(page.url()).pathname).toBe(talk(SEED))
|
||
})
|
||
|
||
/**
|
||
* A MODEL ANSWERS IN MARKDOWN, and the transcript used to show the characters.
|
||
*
|
||
* `@hanzo/ui/chat` renders a turn's words plainly and says so — it ships no
|
||
* pipeline because the plugin set belongs to the surface — so an answer
|
||
* arrived here reading "```ts" with the backticks on screen while two other
|
||
* chat surfaces on this site already drew through `lib/prose.ts`. This asserts
|
||
* the elements, not the absence of the fence: a renderer that stripped the
|
||
* backticks and emitted nothing would pass on absence alone.
|
||
*/
|
||
test('a turn renders as markdown, not as characters', async ({ page }) => {
|
||
await signIn(page)
|
||
await page.route('**/v1/agents/chat/conversations/**', (route) =>
|
||
route.fulfill({
|
||
status: 200,
|
||
contentType: 'application/json',
|
||
body: JSON.stringify({
|
||
id: 'gate-markdown',
|
||
messages: [
|
||
{ id: 'm1', role: 'user', content: 'show me' },
|
||
{
|
||
id: 'm2',
|
||
role: 'assistant',
|
||
content: 'A **bold** claim.\n\n```ts\nconst n = 1\n```',
|
||
},
|
||
],
|
||
}),
|
||
}),
|
||
)
|
||
|
||
await page.goto(`${base}${talk('gate-markdown')}`)
|
||
|
||
const room = page.locator('main')
|
||
await expect(room.locator('[data-slot="prose"] strong')).toHaveText('bold', { timeout: 15000 })
|
||
await expect(room.locator('[data-slot="code"]')).toContainText('const n = 1')
|
||
|
||
// And the fence itself is gone, which is the thing a reader was seeing.
|
||
expect(await room.innerText(), 'the backticks are still on screen').not.toContain('```')
|
||
|
||
// AN ANSWER CAN BE TAKEN AWAY, and only an answer: your own turn is already
|
||
// in your hands. What lands on the clipboard is the source rather than the
|
||
// rendering, which is what makes it paste back with its fence intact.
|
||
await expect(room.getByRole('button', { name: 'Copy this answer' })).toHaveCount(1)
|
||
})
|
||
|
||
/**
|
||
* OPENING ANOTHER CONVERSATION OPENS IT, AND THE PAGE STAYS UP.
|
||
*
|
||
* The pane holds two names for the open conversation: the rail's (`open`) and
|
||
* the chat hook's (`thread`, taken from `open` in an effect). Pressing a Recent
|
||
* moves `open` first, and the pane used to read the hook's stale `thread` as a
|
||
* conversation just written down and open it again; the hook then took the new
|
||
* one, and the two swapped back and forth until React stopped the page with
|
||
* "Maximum update depth exceeded" (#185). Seen in production on `/`.
|
||
*/
|
||
test('a Recent opened from another conversation opens, with no update loop', async ({ page }) => {
|
||
await signIn(page)
|
||
const errors: string[] = []
|
||
page.on('pageerror', (e) => errors.push(e.message))
|
||
const turns = (id: string, words: string) => ({
|
||
id,
|
||
messages: [
|
||
{ id: `${id}-1`, role: 'user', content: `Asked in ${words}` },
|
||
{ id: `${id}-2`, role: 'assistant', content: `Answered in ${words}` },
|
||
],
|
||
})
|
||
await page.route('**/v1/agents/chat/conversations/**', (route) => {
|
||
const id = new URL(route.request().url()).pathname.split('/').pop()!
|
||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(turns(id, id === 't-borealis' ? 'Borealis' : 'Aurora')) })
|
||
})
|
||
await answer(page, '/v1/agents/chat/conversations', {
|
||
conversations: [
|
||
{ id: 't-aurora', title: 'Aurora', updatedAt: new Date().toISOString() },
|
||
{ id: 't-borealis', title: 'Borealis', updatedAt: new Date().toISOString() },
|
||
],
|
||
})
|
||
await page.setViewportSize({ width: 1440, height: 900 })
|
||
await page.goto(`${base}${talk('t-aurora')}`)
|
||
await expect(page.getByText('Answered in Aurora')).toBeVisible({ timeout: 20000 })
|
||
|
||
// Each swap rewrote the address, so the loop is counted where it showed:
|
||
// 54 rewrites in four seconds before the fix, one after.
|
||
let moves = 0
|
||
page.on('framenavigated', (f) => {
|
||
if (f === page.mainFrame()) moves++
|
||
})
|
||
await page.getByRole('button', { name: 'Borealis' }).first().click()
|
||
await expect(page.getByText('Answered in Borealis')).toBeVisible({ timeout: 15000 })
|
||
await page.waitForTimeout(1500)
|
||
expect(new URL(page.url()).pathname).toBe(talk('t-borealis'))
|
||
expect(moves, 'the address settles on the conversation opened').toBeLessThanOrEqual(3)
|
||
expect(errors.filter((m) => /#185|Maximum update depth/.test(m))).toEqual([])
|
||
})
|
||
|
||
test('/chat with no thread reads no conversation and keeps a clean address', async ({ page }) => {
|
||
// A SESSION, because the room is behind one now. A stranger at /chat is
|
||
// sent to sign in rather than shown the composer, so a spec about what a
|
||
// bare room reads has to be someone before it can read anything.
|
||
await signIn(page)
|
||
// Fail loudly if a bare /chat reaches for a transcript it has no id for.
|
||
let asked = false
|
||
await page.route('**/v1/agents/chat/conversations/**', (route) => {
|
||
asked = true
|
||
return route.fulfill({ status: 200, contentType: 'application/json', body: '{"messages":[]}' })
|
||
})
|
||
|
||
await page.goto(`${base}/chat`)
|
||
await expect(page.getByText(/What’s up/)).toBeVisible({ timeout: 15000 })
|
||
|
||
expect(new URL(page.url()).pathname, 'no thread, no conversation in the address').toBe('/chat')
|
||
expect(asked, 'a bare /chat must not read a transcript').toBe(false)
|
||
})
|
||
})
|