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.
347 lines
18 KiB
TypeScript
347 lines
18 KiB
TypeScript
import { test, expect, type Locator, type Page, type Route } from '@playwright/test'
|
||
import { crc32, deflateSync } from 'node:zlib'
|
||
import { serveExport } from './export'
|
||
import { arrive, AUTO } from './chat'
|
||
|
||
/**
|
||
* FILES GO INTO A CONVERSATION ONE WAY, from four places, and go with a message
|
||
* BY REFERENCE.
|
||
*
|
||
* The composer's `+` → "Upload or add files" (Ctrl+U), a drop on the room, a
|
||
* paste into the field and the Summary's `+` → "Attach files" all call `hold` in
|
||
* @hanzo/app' `pane.ts`, which
|
||
* sends each file straight to the org's workspace bucket — one presigned PUT when
|
||
* it is small, presigned parts when it is large — and registers it with the
|
||
* index. Each path is asserted by what a reader sees: a chip that follows the
|
||
* file to ready, and a row under Sources. No kind and no size is refused.
|
||
*
|
||
* NO MODEL IS CALLED AND NO STORE IS REACHED. The platform's s3 and knowledge
|
||
* routes and the store's presigned addresses are answered here, and the one send
|
||
* below is read off its request: the files' names and the passages the index
|
||
* gave for the question, never the files' bytes.
|
||
*/
|
||
|
||
/** A `side`-pixel square PNG, a diagonal from one colour to another: a picture a thumbnail visibly draws. */
|
||
function picture(side: number): Buffer {
|
||
const chunk = (type: string, data: Buffer) => {
|
||
const head = Buffer.alloc(4)
|
||
head.writeUInt32BE(data.length)
|
||
const body = Buffer.concat([Buffer.from(type, 'latin1'), data])
|
||
const sum = Buffer.alloc(4)
|
||
sum.writeUInt32BE(crc32(body))
|
||
return Buffer.concat([head, body, sum])
|
||
}
|
||
const ihdr = Buffer.alloc(13)
|
||
ihdr.writeUInt32BE(side, 0)
|
||
ihdr.writeUInt32BE(side, 4)
|
||
ihdr.set([8, 2, 0, 0, 0], 8)
|
||
const rows = Buffer.alloc(side * (1 + side * 3))
|
||
for (let y = 0; y < side; y++)
|
||
for (let x = 0; x < side; x++) {
|
||
const t = (x + y) / (2 * (side - 1))
|
||
rows.set([Math.round(250 - 200 * t), Math.round(120 + 60 * t), Math.round(40 + 200 * t)], y * (1 + side * 3) + 1 + x * 3)
|
||
}
|
||
return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ihdr), chunk('IDAT', deflateSync(rows)), chunk('IEND', Buffer.alloc(0))])
|
||
}
|
||
|
||
const PNG = { name: 'dot.png', mimeType: 'image/png', buffer: picture(96) }
|
||
const NOTES = { name: 'notes.md', mimeType: 'text/markdown', buffer: Buffer.from('# Notes\n\nShip it.\n') }
|
||
const PDF = { name: 'deck.pdf', mimeType: 'application/pdf', buffer: Buffer.from('%PDF-1.4\n') }
|
||
/** Past the size a file goes up in parts. */
|
||
const BIG = { name: 'big.bin', mimeType: 'application/octet-stream', buffer: Buffer.alloc(9 * 1024 * 1024) }
|
||
|
||
type Fixture = typeof PNG
|
||
|
||
const STORE = 'https://s3.gate.test'
|
||
const CORS = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'PUT, GET', 'access-control-allow-headers': '*' }
|
||
|
||
const chips = (page: Page) => page.getByRole('list', { name: 'Attached files' })
|
||
const chip = (page: Page, name: string) => chips(page).locator('[data-slot="file-chip"]').filter({ hasText: name })
|
||
/** The files a sent turn carried. */
|
||
const carried = (page: Page) => page.getByRole('list', { name: 'Files in this message' })
|
||
/** A file's thumbnail picture, drawn and decoded. */
|
||
const drawn = (at: Locator) => at.locator('[data-slot="file-thumb"] img')
|
||
const summary = (page: Page) => page.locator('[data-slot="summary"]')
|
||
/** Whether the conversation's Summary, opened from its header, lists `name` under Sources; put away after. */
|
||
async function source(page: Page, name: string, listed = true) {
|
||
await page.getByRole('button', { name: 'Summary', exact: true }).click()
|
||
await expect(summary(page)).toBeVisible()
|
||
if (listed) await expect(summary(page)).toContainText(name)
|
||
else await expect(summary(page)).not.toContainText(name)
|
||
await page.keyboard.press('Escape')
|
||
await expect(summary(page)).toHaveCount(0)
|
||
}
|
||
const field = (page: Page) => page.locator('[data-slot="composer-field"]').first()
|
||
|
||
/** The platform's s3 and knowledge routes, answered as apps/s3 and apps/knowledge answer them. */
|
||
async function platform(page: Page) {
|
||
const seen = { parts: 0, completed: 0, registered: [] as string[], retrieved: 0, signed: [] as string[] }
|
||
const files = new Map<string, Record<string, unknown>>()
|
||
// What each single PUT stored, by key: the index records a file's type and size from its object.
|
||
const objects = new Map<string, { type: string; size: number }>()
|
||
const json = (route: Route, body: unknown, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
|
||
await page.route(`${STORE}/**`, (route) => {
|
||
const req = route.request()
|
||
if (req.method() === 'OPTIONS') return route.fulfill({ status: 204, headers: CORS })
|
||
if (new URL(req.url()).searchParams.has('partNumber')) seen.parts++
|
||
if (req.method() === 'PUT')
|
||
objects.set(decodeURIComponent(new URL(req.url()).pathname.slice(1)), {
|
||
type: req.headers()['content-type'] ?? 'application/octet-stream',
|
||
size: req.postDataBuffer()?.length ?? 0,
|
||
})
|
||
// A signed GET reads the bytes the PUT stored, by the file's name.
|
||
const stored = [PNG, NOTES, PDF].find((f) => new URL(req.url()).pathname.endsWith(`/${f.name}`))
|
||
if (req.method() === 'GET' && stored) return route.fulfill({ status: 200, headers: { ...CORS, 'content-type': stored.mimeType }, body: stored.buffer })
|
||
return route.fulfill({ status: 200, headers: { ...CORS, etag: '"e"' } })
|
||
})
|
||
await page.route(
|
||
(url) => url.pathname.startsWith('/v1/s3/') || url.pathname.startsWith('/v1/knowledge/files'),
|
||
(route) => {
|
||
const req = route.request()
|
||
const path = new URL(req.url()).pathname
|
||
const body = (req.postDataJSON?.() ?? {}) as Record<string, unknown>
|
||
if (path === '/v1/s3/buckets') return json(route, req.method() === 'GET' ? { buckets: [], total: 0 } : { name: body.name })
|
||
if (path.endsWith('/objects') && req.method() === 'POST') return json(route, { url: `${STORE}/${body.key}?X-Amz-Signature=x`, method: 'PUT', key: body.key, expiresIn: 300 })
|
||
// A signed download address: what a turn read back opens its file by.
|
||
const object = /^\/v1\/s3\/buckets\/[^/]+\/objects\/(.+)$/.exec(path)
|
||
if (object && req.method() === 'GET') {
|
||
seen.signed.push(object[1])
|
||
return json(route, { url: `${STORE}/${object[1]}?X-Amz-Signature=get`, method: 'GET', expiresIn: 300 })
|
||
}
|
||
if (path.endsWith('/uploads')) return json(route, { upload: 'u1', key: body.key, partSize: 16 * 1024 * 1024 })
|
||
if (path.endsWith('/parts'))
|
||
return json(route, { urls: (body.parts as number[]).map((part) => ({ part, url: `${STORE}/${body.key}?uploadId=u1&partNumber=${part}` })), expiresIn: 300 })
|
||
if (path.endsWith('/complete')) {
|
||
seen.completed++
|
||
return json(route, { key: body.key, etag: 'e-1', size: BIG.buffer.length, parts: 1 })
|
||
}
|
||
if (path === '/v1/knowledge/files' && req.method() === 'POST') {
|
||
const key = String(body.key)
|
||
const name = key.split('/').pop()!
|
||
const object = objects.get(key) ?? { type: 'application/octet-stream', size: 1 }
|
||
const f = { id: `f-${name}`, name, ...object, bucket: body.bucket, key, status: 'ready', sections: 2, passages: 1 }
|
||
files.set(f.id, f)
|
||
seen.registered.push(name)
|
||
return json(route, f)
|
||
}
|
||
if (path === '/v1/knowledge/files/retrieve') {
|
||
seen.retrieved++
|
||
return json(route, {
|
||
sections: [],
|
||
picked: 'model',
|
||
passages: [
|
||
{
|
||
file: { id: 'f-notes.md', name: 'notes.md', type: 'text/markdown', bucket: 'bob', key: 'chat/notes.md' },
|
||
section: { id: 1, title: 'Notes', path: 'Notes' },
|
||
part: 1,
|
||
text: 'The notes say ship it.',
|
||
cite: 'notes.md › Notes › ¶1',
|
||
score: 1,
|
||
via: 'toc',
|
||
},
|
||
],
|
||
})
|
||
}
|
||
const id = decodeURIComponent(path.split('/')[4] ?? '')
|
||
return files.has(id) ? json(route, files.get(id)) : json(route, { error: 'not found' }, 404)
|
||
},
|
||
)
|
||
return seen
|
||
}
|
||
|
||
/** A plan holder at /chat; every completion answers "Done." and is kept. */
|
||
async function room(page: Page, base: string) {
|
||
const posted: { messages?: { role: string; content: unknown }[] }[] = []
|
||
const body =
|
||
[...'Done.'].map((c) => `data: ${JSON.stringify({ choices: [{ index: 0, delta: { content: c } }] })}\n\n`).join('') + 'data: [DONE]\n\n'
|
||
await page.route(
|
||
(url) => url.pathname === '/v1/chat/completions',
|
||
(route) => {
|
||
posted.push(route.request().postDataJSON())
|
||
return route.fulfill({ status: 200, contentType: 'text/event-stream', body })
|
||
},
|
||
)
|
||
const seen = await platform(page)
|
||
await arrive(page, base, 'pro', AUTO.id)
|
||
await expect(page.getByRole('button', { name: 'Summary', exact: true })).toBeVisible({ timeout: 15_000 })
|
||
return { posted, seen }
|
||
}
|
||
|
||
/** The composer's `+` → "Upload or add files": the file chooser it opens. */
|
||
async function upload(page: Page) {
|
||
const chooser = page.waitForEvent('filechooser')
|
||
await page.locator('[data-slot="plus"]').first().click()
|
||
await page.locator('[data-slot="plus-upload"]').first().click()
|
||
return chooser
|
||
}
|
||
|
||
/** Fires `type` at `target` carrying `files` in a DataTransfer, the way a drop or a paste arrives. */
|
||
async function transfer(page: Page, selector: string, type: 'dragenter' | 'drop' | 'paste', files: Fixture[]) {
|
||
await page.evaluate(
|
||
({ selector, type, files }) => {
|
||
const dt = new DataTransfer()
|
||
for (const f of files) {
|
||
const bytes = Uint8Array.from(atob(f.data), (c) => c.charCodeAt(0))
|
||
dt.items.add(new File([bytes], f.name, { type: f.mimeType }))
|
||
}
|
||
const target = document.querySelector(selector)!
|
||
const event =
|
||
type === 'paste'
|
||
? new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })
|
||
: new DragEvent(type, { dataTransfer: dt, bubbles: true, cancelable: true })
|
||
target.dispatchEvent(event)
|
||
},
|
||
{ selector, type, files: files.map((f) => ({ name: f.name, mimeType: f.mimeType, data: f.buffer.toString('base64') })) },
|
||
)
|
||
}
|
||
|
||
test.describe('attaching files to a conversation', () => {
|
||
test.use({ viewport: { width: 1440, height: 900 } })
|
||
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('+ › Upload or add files sends files to the workspace as chips and rows under the Summary’s Sources, and the send carries them by reference', async ({ page }) => {
|
||
const { posted, seen } = await room(page, base)
|
||
await (await upload(page)).setFiles([PNG, NOTES, PDF, BIG])
|
||
|
||
for (const f of [PNG, NOTES, PDF, BIG]) await expect(chip(page, f.name)).toHaveAttribute('data-state', 'ready')
|
||
expect(seen.parts, 'the large file went up in parts').toBe(1)
|
||
expect(seen.completed).toBe(1)
|
||
await source(page, 'deck.pdf')
|
||
await expect(page.getByRole('alert').filter({ hasText: /deck\.pdf|only images/ })).toHaveCount(0)
|
||
|
||
// Removed from the chip, gone from both.
|
||
await page.getByRole('button', { name: 'Remove big.bin' }).click()
|
||
await expect(chips(page).getByRole('listitem')).toHaveCount(3)
|
||
await source(page, 'big.bin', false)
|
||
|
||
// Sources' own `+` → "Attach files" is the same hold: its file lands as a chip.
|
||
const again = page.waitForEvent('filechooser')
|
||
await page.getByRole('button', { name: 'Summary', exact: true }).click()
|
||
await summary(page).getByRole('button', { name: 'Add to this conversation' }).click()
|
||
await page.getByRole('menuitem', { name: 'Attach files' }).click()
|
||
await (await again).setFiles([{ ...NOTES, name: 'more.md' }])
|
||
await expect(chip(page, 'more.md')).toHaveAttribute('data-state', 'ready')
|
||
|
||
// The send carries the files by name and the passages the index read for the question.
|
||
await field(page).click()
|
||
await page.keyboard.type('look')
|
||
await field(page).press('Enter')
|
||
await expect(page.getByText('Done.', { exact: true })).toBeVisible({ timeout: 20_000 })
|
||
await expect(chips(page)).toHaveCount(0)
|
||
const turn = posted.at(-1)?.messages?.filter((m) => m.role === 'user').at(-1)
|
||
expect(typeof turn?.content).toBe('string')
|
||
const sent = turn?.content as string
|
||
expect(sent.startsWith('look\n\n<workspace-files>\n')).toBe(true)
|
||
for (const name of ['dot.png', 'notes.md', 'deck.pdf', 'more.md']) expect(sent).toContain(`"name":"${name}"`)
|
||
expect(sent).not.toContain('big.bin')
|
||
expect(sent).toContain('[1] notes.md › Notes › ¶1\nThe notes say ship it.')
|
||
expect(sent).not.toContain('Ship it.')
|
||
expect(sent).not.toContain('data:image/png;base64,')
|
||
expect(seen.retrieved).toBe(1)
|
||
// From here the turn is the record: the file is a Source because the question carried it.
|
||
await source(page, 'notes.md')
|
||
})
|
||
|
||
// Once in each theme, chosen the way Settings → General keeps it, so each pass's pictures are evidence of that theme.
|
||
for (const scheme of ['light', 'dark'] as const) {
|
||
test(`in ${scheme}, a picture shows itself and a PDF its tile, over the composer and in the sent turn, and each opens beside the conversation`, async ({ page }, info) => {
|
||
await page.emulateMedia({ colorScheme: scheme })
|
||
await page.addInitScript((theme) => localStorage.setItem('hanzo.appearance', JSON.stringify({ theme })), scheme)
|
||
const { seen } = await room(page, base)
|
||
await expect.poll(() => page.evaluate(() => document.documentElement.className)).toContain(`t_${scheme}`)
|
||
await (await upload(page)).setFiles([PNG, PDF, NOTES])
|
||
|
||
// Over the composer: the picture from the bytes on this machine, the PDF as
|
||
// a tile, the rest a chip; no fill behind any of them and no "ready".
|
||
for (const f of [PNG, PDF, NOTES]) await expect(chip(page, f.name)).toHaveAttribute('data-state', 'ready')
|
||
await expect(drawn(chip(page, 'dot.png'))).toBeVisible()
|
||
await expect.poll(() => drawn(chip(page, 'dot.png')).evaluate((img: HTMLImageElement) => img.naturalWidth)).toBe(96)
|
||
expect(await drawn(chip(page, 'dot.png')).getAttribute('src')).toMatch(/^blob:/)
|
||
await expect(chip(page, 'deck.pdf')).toHaveAttribute('data-kind', 'pdf')
|
||
await expect(chip(page, 'deck.pdf').locator('[data-slot="file-thumb"]')).toContainText('PDF')
|
||
await expect(chip(page, 'notes.md')).toHaveAttribute('data-kind', 'file')
|
||
for (const f of [PNG, PDF, NOTES]) {
|
||
await expect(chip(page, f.name)).not.toContainText('ready')
|
||
expect(await chip(page, f.name).evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)')
|
||
}
|
||
await chips(page).screenshot({ path: info.outputPath(`composer-${scheme}.png`) })
|
||
|
||
// Pressed over the composer, the picture opens in the column's browser.
|
||
await chip(page, 'dot.png').getByRole('button', { name: 'Open dot.png' }).click()
|
||
await expect(page.locator('img[alt="dot.png"]')).toBeVisible()
|
||
|
||
await field(page).click()
|
||
await page.keyboard.type('what is in these')
|
||
await field(page).press('Enter')
|
||
await expect(page.getByText('Done.', { exact: true })).toBeVisible({ timeout: 20_000 })
|
||
await expect(chips(page)).toHaveCount(0)
|
||
|
||
// In the sent turn: the same picture, from the same bytes, with no request.
|
||
const turn = (name: string) => carried(page).locator('[data-slot="file-chip"]').filter({ hasText: name })
|
||
await expect(drawn(turn('dot.png'))).toBeVisible()
|
||
await expect.poll(() => drawn(turn('dot.png')).evaluate((img: HTMLImageElement) => img.naturalWidth)).toBe(96)
|
||
await expect(turn('deck.pdf').locator('[data-slot="file-thumb"]')).toContainText('PDF')
|
||
for (const f of [PNG, PDF, NOTES]) await expect(turn(f.name)).not.toContainText('ready')
|
||
expect(await turn('dot.png').evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)')
|
||
expect(seen.signed, 'the thumbnail asked the store for nothing').toEqual([])
|
||
await page.locator('[data-slot="message"][data-role="user"]').last().screenshot({ path: info.outputPath(`turn-${scheme}.png`) })
|
||
await page.screenshot({ path: info.outputPath(`room-${scheme}.png`) })
|
||
|
||
// Pressed in the turn, each opens beside the conversation, from the bytes
|
||
// the page already holds (@hanzo/build's Panel).
|
||
const side = page.locator('aside[data-slot="side"]')
|
||
await turn('dot.png').getByRole('button', { name: 'Open dot.png' }).click()
|
||
await expect(side.locator('img[alt="dot.png"]')).toBeVisible()
|
||
await turn('deck.pdf').getByRole('button', { name: 'Open deck.pdf' }).click()
|
||
await expect(side.locator('iframe[title="deck.pdf"]')).toBeAttached()
|
||
})
|
||
}
|
||
|
||
test('a drop anywhere on the room shows the target and holds the files', async ({ page }) => {
|
||
await room(page, base)
|
||
await transfer(page, '[data-slot="chat-room"]', 'dragenter', [PNG])
|
||
await expect(page.getByText('Drop files to attach')).toBeVisible()
|
||
await transfer(page, '[data-slot="composer-field"]', 'drop', [PNG, NOTES, PDF])
|
||
await expect(page.getByText('Drop files to attach')).toHaveCount(0)
|
||
await expect(chips(page).getByRole('listitem')).toHaveCount(3)
|
||
await source(page, 'dot.png')
|
||
await source(page, 'deck.pdf')
|
||
await expect(page.getByRole('alert').filter({ hasText: /deck\.pdf|only images/ })).toHaveCount(0)
|
||
})
|
||
|
||
test('a file pasted into the field is held as a chip and a Source', async ({ page }) => {
|
||
await room(page, base)
|
||
await transfer(page, '[data-slot="composer-field"]', 'paste', [PNG])
|
||
await expect(chips(page)).toContainText('dot.png')
|
||
await source(page, 'dot.png')
|
||
await expect(field(page)).toHaveValue('')
|
||
})
|
||
})
|
||
|
||
test('at 390px the + sits in the composer row with its round button, and nothing runs off the side', async ({ page }) => {
|
||
const { url, close } = await serveExport()
|
||
try {
|
||
await page.setViewportSize({ width: 390, height: 844 })
|
||
await arrive(page, url, 'pro', AUTO.id)
|
||
const plus = page.locator('[data-slot="plus"]').first()
|
||
await expect(plus).toBeVisible({ timeout: 20_000 })
|
||
const end = (await page.locator('[data-slot="prompt-end"]').first().boundingBox())!
|
||
const box = (await plus.boundingBox())!
|
||
expect(Math.abs(box.y + box.height / 2 - (end.y + end.height / 2)), 'one row').toBeLessThan(4)
|
||
expect(end.x + end.width).toBeLessThanOrEqual(390)
|
||
expect(
|
||
await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1),
|
||
'no sideways scroll',
|
||
).toBe(false)
|
||
} finally {
|
||
await close()
|
||
}
|
||
})
|