Files
zeekay 6c65e3e941 @hanzo/app 0.2.0: the app SDK, renamed from @hanzo/rooms
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.
2026-10-22 19:07:20 -07:00

347 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}
})