From 09c554b446597102ee7798ca87d8cc6c65e72b4d Mon Sep 17 00:00:00 2001 From: Zhicheng Han <43314240+hanzckernel@users.noreply.github.com> Date: Fri, 15 May 2026 12:43:57 +0200 Subject: [PATCH] test: cover chat streaming browser contract (#766) --- tests/e2e/chat-streaming.spec.ts | 63 ++++++++++++++++++++++++++++++++ tests/e2e/fixtures.ts | 59 ++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 tests/e2e/chat-streaming.spec.ts diff --git a/tests/e2e/chat-streaming.spec.ts b/tests/e2e/chat-streaming.spec.ts new file mode 100644 index 0000000..9813632 --- /dev/null +++ b/tests/e2e/chat-streaming.spec.ts @@ -0,0 +1,63 @@ +import { expect, test } from '@playwright/test' +import { authenticate, mockChatSocket, mockHermesApi, TEST_ACCESS_KEY } from './fixtures' + +test('sends a chat run and renders streamed Socket.IO response events', async ({ page }) => { + await authenticate(page, TEST_ACCESS_KEY, 'research') + const api = await mockHermesApi(page) + await mockChatSocket(page) + + await page.goto('/#/hermes/chat') + + const input = page.getByPlaceholder('Type a message... (Enter to send, Shift+Enter for new line)') + await expect(input).toBeVisible() + await input.fill('Summarize the queue') + await page.getByRole('button', { name: 'Send' }).click() + + await expect(page.locator('p').filter({ hasText: /^Summarize the queue$/ })).toBeVisible() + + const socketState = await page.waitForFunction(() => { + const state = (window as any).__PW_CHAT_SOCKET__ + return state?.emitted?.some((item: any) => item.event === 'run') + ? { + socket: { + url: state.latest.url, + options: state.latest.options, + }, + emitted: state.emitted, + } + : null + }) + const { socket, emitted } = await socketState.jsonValue() as any + const run = emitted.find((item: any) => item.event === 'run') + + expect(socket.url).toBe('/chat-run') + expect(socket.options.auth).toEqual({ token: TEST_ACCESS_KEY }) + expect(socket.options.query).toEqual({ profile: 'research' }) + expect(run.payload).toMatchObject({ + input: 'Summarize the queue', + queue_id: expect.any(String), + session_id: expect.any(String), + source: 'api_server', + }) + expect(run.payload.model).toBe('test-model') + + const sessionId = run.payload.session_id + await page.evaluate((sid) => { + const socket = (window as any).__PW_CHAT_SOCKET__.latest + socket.__trigger('run.started', { event: 'run.started', session_id: sid, run_id: 'run-1' }) + socket.__trigger('message.delta', { event: 'message.delta', session_id: sid, run_id: 'run-1', delta: 'Streaming ' }) + socket.__trigger('message.delta', { event: 'message.delta', session_id: sid, run_id: 'run-1', delta: 'answer from Hermes' }) + socket.__trigger('run.completed', { + event: 'run.completed', + session_id: sid, + run_id: 'run-1', + output: 'Streaming answer from Hermes', + inputTokens: 11, + outputTokens: 7, + }) + }, sessionId) + + await expect(page.getByText('Streaming answer from Hermes')).toBeVisible() + await expect(page.getByRole('button', { name: 'Send' })).toBeVisible() + expect(api.unexpectedRequests).toEqual([]) +}) diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts index 4ec776f..2310f8a 100644 --- a/tests/e2e/fixtures.ts +++ b/tests/e2e/fixtures.ts @@ -189,3 +189,62 @@ export async function authenticate(page: Page, accessKey = TEST_ACCESS_KEY, prof } }, { storedToken: accessKey, storedProfileName: profileName }) } + +export async function mockChatSocket(page: Page) { + await page.route('**/node_modules/.vite/deps/socket__io-client.js*', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/javascript', + body: ` +const state = window.__PW_CHAT_SOCKET__ || (window.__PW_CHAT_SOCKET__ = { sockets: [], emitted: [] }) +function makeSocket(url, options) { + const listeners = new Map() + const onceListeners = new Map() + const socket = { + connected: true, + url, + options, + on(event, handler) { + const handlers = listeners.get(event) || [] + handlers.push(handler) + listeners.set(event, handlers) + return this + }, + once(event, handler) { + const handlers = onceListeners.get(event) || [] + handlers.push(handler) + onceListeners.set(event, handlers) + return this + }, + emit(event, payload) { + state.emitted.push({ event, payload }) + return this + }, + removeAllListeners() { + listeners.clear() + onceListeners.clear() + return this + }, + disconnect() { + this.connected = false + return this + }, + __trigger(event, payload) { + for (const handler of listeners.get(event) || []) handler(payload) + const handlers = onceListeners.get(event) || [] + onceListeners.delete(event) + for (const handler of handlers) handler(payload) + }, + } + state.sockets.push(socket) + state.latest = socket + return socket +} +export function io(url, options) { + return makeSocket(url, options) +} +export default { io } +`, + }) + }) +}