From d066806d8671c66e17aa70994e10b334aa11def3 Mon Sep 17 00:00:00 2001 From: Zhicheng Han <43314240+hanzckernel@users.noreply.github.com> Date: Sat, 16 May 2026 02:56:34 +0200 Subject: [PATCH] =?UTF-8?q?test:=20=E8=A1=A5=E5=BC=BA=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=20P0=20=E5=A5=91=E7=BA=A6=E6=B5=8B=E8=AF=95=20(#772)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: cover terminal browser contract * test: harden P0 browser contracts --- .../src/components/layout/ProfileSelector.vue | 1 + tests/e2e/chat-streaming.spec.ts | 186 +++++++++++++++--- tests/e2e/fixtures.ts | 109 +++++++++- tests/e2e/terminal.spec.ts | 60 ++++++ 4 files changed, 330 insertions(+), 26 deletions(-) create mode 100644 tests/e2e/terminal.spec.ts diff --git a/packages/client/src/components/layout/ProfileSelector.vue b/packages/client/src/components/layout/ProfileSelector.vue index fcf1e57..929d3da 100644 --- a/packages/client/src/components/layout/ProfileSelector.vue +++ b/packages/client/src/components/layout/ProfileSelector.vue @@ -41,6 +41,7 @@ onMounted(() => {
{{ t('sidebar.profiles') }}
{ + const state = (window as any).__PW_CHAT_SOCKET__ + const runs = state?.emitted?.filter((item: any) => item.event === 'run') || [] + const run = runs[runIndex] + return run + ? { + socket: { + url: state.latest.url, + options: state.latest.options, + }, + run: run.payload, + runCount: runs.length, + socketCount: state.sockets.length, + } + : null + }, index) + return handle.jsonValue() as Promise +} + 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) @@ -8,40 +37,23 @@ test('sends a chat run and renders streamed Socket.IO response events', async ({ 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 sendChatMessage(page, 'Summarize the queue') 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') + const { socket, run } = await waitForRun(page) 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({ + expect(run).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') + expect(run.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' }) @@ -55,9 +67,135 @@ test('sends a chat run and renders streamed Socket.IO response events', async ({ inputTokens: 11, outputTokens: 7, }) - }, sessionId) + }, run.session_id) await expect(page.getByText('Streaming answer from Hermes')).toBeVisible() - await expect(page.getByRole('button', { name: 'Send' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Stop' })).toHaveCount(0) + expect(api.unexpectedRequests).toEqual([]) +}) + +test('uses the newly selected profile for the next chat-run socket after profile switch reload', async ({ page }) => { + await authenticate(page, TEST_ACCESS_KEY, 'default') + const api = await mockHermesApi(page, { initialProfileName: 'default' }) + await mockChatSocket(page) + + await page.goto('/#/hermes/chat') + await expect(page.getByTestId('profile-selector-select').filter({ hasText: 'default' })).toBeVisible() + + await sendChatMessage(page, 'Warm up default socket') + const defaultRun = await waitForRun(page) + expect(defaultRun.socket.options.query).toEqual({ profile: 'default' }) + 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-default' }) + socket.__trigger('message.delta', { event: 'message.delta', session_id: sid, run_id: 'run-default', delta: 'Default profile reply' }) + socket.__trigger('run.completed', { + event: 'run.completed', + session_id: sid, + run_id: 'run-default', + output: 'Default profile reply', + }) + }, defaultRun.run.session_id) + await expect(page.getByRole('button', { name: 'Stop' })).toHaveCount(0) + + await page.locator('[data-testid="profile-selector-select"] .n-base-selection').click() + const reloadPromise = page.waitForEvent('framenavigated', frame => frame === page.mainFrame()) + await page.locator('.n-base-select-option', { hasText: /^research$/ }).click() + await reloadPromise + await page.waitForLoadState('domcontentloaded') + await expect(page.getByTestId('profile-selector-select').filter({ hasText: 'research' })).toBeVisible() + + await sendChatMessage(page, 'Use the active research profile') + const { socket, run } = await waitForRun(page) + + expect(socket.url).toBe('/chat-run') + expect(socket.options.auth).toEqual({ token: TEST_ACCESS_KEY }) + expect(socket.options.query).toEqual({ profile: 'research' }) + expect(run.input).toBe('Use the active research profile') + expect(await page.evaluate(() => window.localStorage.getItem('hermes_active_profile_name'))).toBe('research') + + const switchRequest = api.requests.find((request) => request.pathname === '/api/hermes/profiles/active') + expect(switchRequest?.method).toBe('PUT') + expect(switchRequest?.postData).toBe(JSON.stringify({ name: 'research' })) + expect(api.unexpectedRequests).toEqual([]) +}) + +test('keeps queued runs on one socket and does not duplicate streamed handlers', async ({ page }) => { + await authenticate(page, TEST_ACCESS_KEY, 'research') + const api = await mockHermesApi(page) + await mockChatSocket(page) + + await page.goto('/#/hermes/chat') + + await sendChatMessage(page, 'First queued contract') + const first = await waitForRun(page) + 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', queue_length: 1 }) + socket.__trigger('message.delta', { event: 'message.delta', session_id: sid, run_id: 'run-1', delta: 'First answer' }) + }, first.run.session_id) + await expect(page.getByRole('button', { name: 'Stop' })).toBeVisible() + + await sendChatMessage(page, 'Second queued contract') + const second = await waitForRun(page, 1) + + expect(second.socketCount).toBe(1) + expect(second.runCount).toBe(2) + expect(second.run.session_id).toBe(first.run.session_id) + expect(second.run.input).toBe('Second queued contract') + + await page.evaluate((sid) => { + const socket = (window as any).__PW_CHAT_SOCKET__.latest + socket.__trigger('run.completed', { + event: 'run.completed', + session_id: sid, + run_id: 'run-1', + output: 'First answer', + queue_remaining: 1, + }) + socket.__trigger('run.started', { event: 'run.started', session_id: sid, run_id: 'run-2', queue_length: 0 }) + socket.__trigger('message.delta', { event: 'message.delta', session_id: sid, run_id: 'run-2', delta: 'Second answer' }) + socket.__trigger('run.completed', { + event: 'run.completed', + session_id: sid, + run_id: 'run-2', + output: 'Second answer', + queue_remaining: 0, + }) + }, first.run.session_id) + + await expect(page.locator('p').filter({ hasText: /^First answer$/ })).toHaveCount(1) + await expect(page.locator('p').filter({ hasText: /^Second queued contract$/ })).toHaveCount(1) + await expect(page.locator('p').filter({ hasText: /^Second answer$/ })).toHaveCount(1) + await expect(page.getByRole('button', { name: 'Stop' })).toHaveCount(0) + expect(api.unexpectedRequests).toEqual([]) +}) + +test('surfaces an empty completed run as an error instead of leaving chat stalled', async ({ page }) => { + await authenticate(page, TEST_ACCESS_KEY, 'research') + const api = await mockHermesApi(page) + await mockChatSocket(page) + + await page.goto('/#/hermes/chat') + + await sendChatMessage(page, 'Call a broken provider') + const { run } = await waitForRun(page) + + 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-empty' }) + socket.__trigger('run.completed', { + event: 'run.completed', + session_id: sid, + run_id: 'run-empty', + output: '', + inputTokens: 0, + outputTokens: 0, + }) + }, run.session_id) + + await expect(page.getByText(/Agent returned no output/)).toBeVisible() + await expect(page.getByRole('button', { name: 'Send' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Stop' })).toHaveCount(0) expect(api.unexpectedRequests).toEqual([]) }) diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts index 2310f8a..0b28929 100644 --- a/tests/e2e/fixtures.ts +++ b/tests/e2e/fixtures.ts @@ -12,6 +12,7 @@ export interface MockedRequest { interface MockHermesApiOptions { tokenValidationStatus?: number + initialProfileName?: 'default' | 'research' } const sampleModelGroup = { @@ -76,6 +77,7 @@ export async function mockHermesApi(page: Page, options: MockHermesApiOptions = const requests: MockedRequest[] = [] const unexpectedRequests: MockedRequest[] = [] const tokenValidationStatus = options.tokenValidationStatus ?? 200 + let activeProfileName = options.initialProfileName ?? 'research' await page.route('**/*', async (route: Route) => { const request = route.request() @@ -144,13 +146,37 @@ export async function mockHermesApi(page: Page, options: MockHermesApiOptions = if (pathname === '/api/hermes/profiles') { await route.fulfill(jsonResponse({ profiles: [ - { name: 'default', active: false, model: 'test-model', gateway: 'test', alias: 'Default' }, - { name: 'research', active: true, model: 'test-model', gateway: 'test', alias: 'Research' }, + { name: 'default', active: activeProfileName === 'default', model: 'test-model', gateway: 'test', alias: 'Default' }, + { name: 'research', active: activeProfileName === 'research', model: 'test-model', gateway: 'test', alias: 'Research' }, ], })) return } + if (pathname === '/api/hermes/profiles/active') { + if (request.method() !== 'PUT') { + await route.fulfill(jsonResponse({ error: 'Method not allowed' }, 405)) + return + } + + let body: { name?: unknown } + try { + body = JSON.parse(request.postData() || '{}') + } catch { + await route.fulfill(jsonResponse({ error: 'Invalid JSON body' }, 400)) + return + } + + if (body.name !== 'default' && body.name !== 'research') { + await route.fulfill(jsonResponse({ error: 'Unknown profile' }, 400)) + return + } + + activeProfileName = body.name + await route.fulfill(jsonResponse({ success: true, active: activeProfileName })) + return + } + if (pathname === '/api/hermes/config') { await route.fulfill(jsonResponse({ display: { streaming: true, show_reasoning: true, show_cost: true }, @@ -248,3 +274,82 @@ export default { io } }) }) } + +export async function mockTerminalWebSocket(page: Page) { + await page.addInitScript(() => { + const state = (window as any).__PW_TERMINAL_WS__ = { + sockets: [] as any[], + sent: [] as any[], + createdCount: 0, + latest: null as any, + } + const RealEvent = window.Event + const RealMessageEvent = window.MessageEvent + + class MockTerminalWebSocket extends EventTarget { + static CONNECTING = 0 + static OPEN = 1 + static CLOSING = 2 + static CLOSED = 3 + + readonly CONNECTING = 0 + readonly OPEN = 1 + readonly CLOSING = 2 + readonly CLOSED = 3 + binaryType: BinaryType = 'blob' + bufferedAmount = 0 + extensions = '' + protocol = '' + readyState = MockTerminalWebSocket.CONNECTING + onopen: ((event: Event) => void) | null = null + onmessage: ((event: MessageEvent) => void) | null = null + onerror: ((event: Event) => void) | null = null + onclose: ((event: CloseEvent) => void) | null = null + + constructor(readonly url: string | URL) { + super() + state.sockets.push(this) + state.latest = this + setTimeout(() => { + this.readyState = MockTerminalWebSocket.OPEN + const openEvent = new RealEvent('open') + this.onopen?.(openEvent) + this.dispatchEvent(openEvent) + this.__createSession('term-1', 'zsh', 101) + }, 0) + } + + send(data: string | ArrayBufferLike | Blob | ArrayBufferView) { + const normalized = typeof data === 'string' ? data : String(data) + state.sent.push({ socket: this.url.toString(), data: normalized }) + if (normalized.charCodeAt(0) !== 0x7B) return + try { + const message = JSON.parse(normalized) + if (message.type === 'create') { + this.__createSession(`term-${state.createdCount + 1}`, 'bash', 200 + state.createdCount) + } + if (message.type === 'switch') { + this.__emitMessage(JSON.stringify({ type: 'switched', id: message.sessionId })) + } + } catch {} + } + + close() { + this.readyState = MockTerminalWebSocket.CLOSED + } + + __createSession(id: string, shell: string, pid: number) { + state.createdCount += 1 + this.__emitMessage(JSON.stringify({ type: 'created', id, shell, pid })) + } + + __emitMessage(data: string) { + const event = new RealMessageEvent('message', { data }) + this.onmessage?.(event) + this.dispatchEvent(event) + } + } + + ;(window as any).WebSocket = MockTerminalWebSocket + }) +} diff --git a/tests/e2e/terminal.spec.ts b/tests/e2e/terminal.spec.ts new file mode 100644 index 0000000..2ad21c4 --- /dev/null +++ b/tests/e2e/terminal.spec.ts @@ -0,0 +1,60 @@ +import { expect, test } from '@playwright/test' +import { authenticate, mockHermesApi, mockTerminalWebSocket, TEST_ACCESS_KEY } from './fixtures' + +test('opens terminal websocket session and forwards user input', async ({ page }) => { + await authenticate(page, TEST_ACCESS_KEY, 'research') + const api = await mockHermesApi(page) + await mockTerminalWebSocket(page) + + await page.goto('/#/hermes/terminal') + + await expect(page.getByText('Sessions')).toBeVisible() + await expect(page.locator('.session-item-title', { hasText: 'zsh #1' })).toBeVisible() + + const terminalState = await page.waitForFunction(() => { + const state = (window as any).__PW_TERMINAL_WS__ + return state?.sockets?.length + ? { + url: state.latest.url, + sent: state.sent, + } + : null + }) + const initialState = await terminalState.jsonValue() as any + const terminalUrl = new URL(initialState.url) + expect(terminalUrl.pathname).toBe('/api/hermes/terminal') + expect(terminalUrl.searchParams.get('token')).toBe(TEST_ACCESS_KEY) + + await page.locator('.terminal-header .header-actions button').last().click() + await expect(page.locator('.session-item-title', { hasText: 'bash #2' })).toBeVisible() + + await page.locator('.terminal-xterm').click() + await page.keyboard.type('pwd') + await page.keyboard.press('Enter') + + await expect.poll(async () => page.evaluate(() => { + const state = (window as any).__PW_TERMINAL_WS__ + return state.sent + .map((item: any) => item.data) + .filter((data: string) => !data.startsWith('{')) + .join('') + })).toContain('pwd') + + await expect.poll(async () => page.evaluate(() => { + const state = (window as any).__PW_TERMINAL_WS__ + return state.sent + .map((item: any) => item.data) + .filter((data: string) => data.startsWith('{')) + .map((data: string) => JSON.parse(data)) + .some((message: any) => message.type === 'resize' && message.cols > 0 && message.rows > 0) + })).toBe(true) + + const finalState = await page.evaluate(() => (window as any).__PW_TERMINAL_WS__) + const controlMessages = finalState.sent + .map((item: any) => item.data) + .filter((data: string) => data.startsWith('{')) + .map((data: string) => JSON.parse(data)) + + expect(controlMessages.some((message: any) => message.type === 'create')).toBe(true) + expect(api.unexpectedRequests).toEqual([]) +})