feat: 灵犀 Studio Web UI 定制版
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { mockHermesApi, TEST_ACCESS_KEY } from './fixtures'
|
||||
|
||||
test('redirects protected routes to the login screen without a token', async ({ page }) => {
|
||||
const api = await mockHermesApi(page)
|
||||
|
||||
await page.goto('/#/hermes/jobs')
|
||||
|
||||
await expect(page).toHaveURL(/#\/$/)
|
||||
await expect(page.getByRole('heading', { name: 'Hermes Web UI' })).toBeVisible()
|
||||
await expect(page.getByPlaceholder('Username')).toBeVisible()
|
||||
await expect(page.getByPlaceholder('Password')).toBeVisible()
|
||||
expect(api.unexpectedRequests).toEqual([])
|
||||
})
|
||||
|
||||
test('rejects invalid credentials without persisting a token', async ({ page }) => {
|
||||
const api = await mockHermesApi(page, { tokenValidationStatus: 401 })
|
||||
|
||||
await page.goto('/')
|
||||
await page.getByPlaceholder('Username').fill('playwright')
|
||||
await page.getByPlaceholder('Password').fill('bad-password')
|
||||
await page.getByRole('button', { name: 'Login' }).click()
|
||||
|
||||
await expect(page.getByText('Invalid username or password')).toBeVisible()
|
||||
await expect(page).toHaveURL(/#\/$/)
|
||||
await expect(page.evaluate(() => window.localStorage.getItem('hermes_api_key'))).resolves.toBeNull()
|
||||
expect(api.unexpectedRequests).toEqual([])
|
||||
})
|
||||
|
||||
test('logs in with password through the BFF before entering the app', async ({ page }) => {
|
||||
const api = await mockHermesApi(page)
|
||||
|
||||
await page.goto('/')
|
||||
await page.getByPlaceholder('Username').fill('playwright')
|
||||
await page.getByPlaceholder('Password').fill('correct-password')
|
||||
await page.getByRole('button', { name: 'Login' }).click()
|
||||
|
||||
await expect(page).toHaveURL(/#\/hermes\/chat$/)
|
||||
await expect(page.evaluate(() => window.localStorage.getItem('hermes_api_key'))).resolves.toBe(TEST_ACCESS_KEY)
|
||||
|
||||
const loginRequest = api.requests.find((request) => request.pathname === '/api/auth/login')
|
||||
expect(loginRequest?.method).toBe('POST')
|
||||
expect(loginRequest?.postData).toBe(JSON.stringify({ username: 'playwright', password: 'correct-password' }))
|
||||
expect(api.unexpectedRequests).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { authenticate, mockHermesApi, TEST_ACCESS_KEY } from './fixtures'
|
||||
|
||||
test('renders authenticated shell and navigates between key product routes', async ({ page }) => {
|
||||
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||
const api = await mockHermesApi(page)
|
||||
|
||||
await page.goto('/#/hermes/jobs')
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Scheduled Jobs' })).toBeVisible()
|
||||
await expect(page.getByText('Nightly Smoke')).toBeVisible()
|
||||
|
||||
const jobsRequest = api.requests.find((request) => request.pathname === '/api/hermes/jobs')
|
||||
expect(jobsRequest?.headers.authorization).toBe(`Bearer ${TEST_ACCESS_KEY}`)
|
||||
expect(jobsRequest?.headers['x-hermes-profile']).toBe('research')
|
||||
const cronHistoryRequest = api.requests.find((request) => request.pathname === '/api/cron-history')
|
||||
expect(cronHistoryRequest?.headers['x-hermes-profile']).toBe('research')
|
||||
|
||||
const modelsLink = page.locator('aside.sidebar').getByRole('link', { name: /^Models$/ })
|
||||
await expect(modelsLink).toHaveAttribute('href', '#/hermes/models')
|
||||
await modelsLink.click()
|
||||
await expect(page).toHaveURL(/#\/hermes\/models$/)
|
||||
await expect(page.getByRole('heading', { name: 'Models' })).toBeVisible()
|
||||
await expect(page.getByText('test-model').first()).toBeVisible()
|
||||
|
||||
const settingsLink = page.locator('aside.sidebar').getByRole('link', { name: /^Settings$/ })
|
||||
await expect(settingsLink).toHaveAttribute('href', '#/hermes/settings')
|
||||
await settingsLink.click()
|
||||
await expect(page).toHaveURL(/#\/hermes\/settings$/)
|
||||
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible()
|
||||
expect(api.unexpectedRequests).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,169 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { authenticate, mockChatSocket, mockHermesApi, TEST_ACCESS_KEY } from './fixtures'
|
||||
|
||||
const inputPlaceholder = 'Type a message... (Enter to send, Shift+Enter for new line)'
|
||||
|
||||
type SessionSeed = {
|
||||
id: string
|
||||
title: string
|
||||
lastActive: number
|
||||
}
|
||||
|
||||
function sessionSummary({ id, title, lastActive }: SessionSeed) {
|
||||
return {
|
||||
id,
|
||||
profile: 'research',
|
||||
source: 'cli',
|
||||
model: 'test-model',
|
||||
provider: 'test-provider',
|
||||
title,
|
||||
preview: title,
|
||||
started_at: lastActive - 10,
|
||||
ended_at: null,
|
||||
last_active: lastActive,
|
||||
message_count: 1,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: null,
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: null,
|
||||
cost_status: 'estimated',
|
||||
}
|
||||
}
|
||||
|
||||
function resumePayload(sessionId: string, content: string) {
|
||||
return {
|
||||
session_id: sessionId,
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
session_id: sessionId,
|
||||
role: 'user',
|
||||
content,
|
||||
timestamp: Date.now() / 1000,
|
||||
tool_call_id: null,
|
||||
tool_calls: null,
|
||||
tool_name: null,
|
||||
token_count: null,
|
||||
finish_reason: null,
|
||||
reasoning: null,
|
||||
},
|
||||
],
|
||||
isWorking: false,
|
||||
events: [],
|
||||
}
|
||||
}
|
||||
|
||||
const sessions = [
|
||||
sessionSummary({ id: 'session-a', title: 'Alpha chat', lastActive: 100 }),
|
||||
sessionSummary({ id: 'session-b', title: 'Beta chat', lastActive: 200 }),
|
||||
]
|
||||
|
||||
const resumes = {
|
||||
'session-a': resumePayload('session-a', 'Alpha route content'),
|
||||
'session-b': resumePayload('session-b', 'Beta route content'),
|
||||
}
|
||||
|
||||
async function setupChatPage(page: Page) {
|
||||
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||
await page.addInitScript((payload) => {
|
||||
;(window as any).__PW_CHAT_SOCKET_RESUMES__ = payload
|
||||
window.localStorage.setItem('hermes_active_session_research', 'session-b')
|
||||
}, resumes)
|
||||
const api = await mockHermesApi(page, { sessions })
|
||||
await mockChatSocket(page)
|
||||
return api
|
||||
}
|
||||
|
||||
async function sendChatMessage(page: Page, message: string) {
|
||||
const input = page.getByPlaceholder(inputPlaceholder)
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill(message)
|
||||
await page.getByRole('button', { name: 'Send' }).click()
|
||||
}
|
||||
|
||||
async function waitForRun(page: Page, index = 0) {
|
||||
const handle = await page.waitForFunction((runIndex) => {
|
||||
const state = (window as any).__PW_CHAT_SOCKET__
|
||||
const runs = state?.emitted?.filter((item: any) => item.event === 'run') || []
|
||||
const run = runs[runIndex]
|
||||
return run ? run.payload : null
|
||||
}, index)
|
||||
return handle.jsonValue() as Promise<any>
|
||||
}
|
||||
|
||||
test('route session id wins over shared active-session localStorage', async ({ page }) => {
|
||||
const api = await setupChatPage(page)
|
||||
|
||||
await page.goto('/#/hermes/session/session-a')
|
||||
|
||||
await expect(page.getByText('Alpha route content')).toBeVisible()
|
||||
await expect(page.getByText('Beta route content')).toHaveCount(0)
|
||||
await expect(page).toHaveURL(/#\/hermes\/session\/session-a$/)
|
||||
expect(api.unexpectedRequests).toEqual([])
|
||||
})
|
||||
|
||||
test('two tabs can show different sessions and keep them after reload', async ({ context }) => {
|
||||
const pageA = await context.newPage()
|
||||
const pageB = await context.newPage()
|
||||
const apiA = await setupChatPage(pageA)
|
||||
const apiB = await setupChatPage(pageB)
|
||||
|
||||
await pageA.goto('/#/hermes/session/session-a')
|
||||
await pageB.goto('/#/hermes/session/session-b')
|
||||
|
||||
await expect(pageA.getByText('Alpha route content')).toBeVisible()
|
||||
await expect(pageB.getByText('Beta route content')).toBeVisible()
|
||||
|
||||
await pageA.reload()
|
||||
await pageB.reload()
|
||||
|
||||
await expect(pageA.getByText('Alpha route content')).toBeVisible()
|
||||
await expect(pageB.getByText('Beta route content')).toBeVisible()
|
||||
await expect(pageA).toHaveURL(/#\/hermes\/session\/session-a$/)
|
||||
await expect(pageB).toHaveURL(/#\/hermes\/session\/session-b$/)
|
||||
expect(apiA.unexpectedRequests).toEqual([])
|
||||
expect(apiB.unexpectedRequests).toEqual([])
|
||||
})
|
||||
|
||||
test('parallel tabs send runs and render progress only for their own session', async ({ context }) => {
|
||||
const pageA = await context.newPage()
|
||||
const pageB = await context.newPage()
|
||||
const apiA = await setupChatPage(pageA)
|
||||
const apiB = await setupChatPage(pageB)
|
||||
|
||||
await pageA.goto('/#/hermes/session/session-a')
|
||||
await pageB.goto('/#/hermes/session/session-b')
|
||||
await expect(pageA.getByText('Alpha route content')).toBeVisible()
|
||||
await expect(pageB.getByText('Beta route content')).toBeVisible()
|
||||
|
||||
await sendChatMessage(pageA, 'Question for Alpha')
|
||||
await sendChatMessage(pageB, 'Question for Beta')
|
||||
|
||||
const runA = await waitForRun(pageA)
|
||||
const runB = await waitForRun(pageB)
|
||||
expect(runA.session_id).toBe('session-a')
|
||||
expect(runB.session_id).toBe('session-b')
|
||||
|
||||
await pageA.evaluate((sid) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('run.started', { event: 'run.started', session_id: sid, run_id: 'run-a' })
|
||||
socket.__trigger('message.delta', { event: 'message.delta', session_id: sid, run_id: 'run-a', delta: 'Alpha progress' })
|
||||
}, runA.session_id)
|
||||
await pageB.evaluate((sid) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('run.started', { event: 'run.started', session_id: sid, run_id: 'run-b' })
|
||||
socket.__trigger('message.delta', { event: 'message.delta', session_id: sid, run_id: 'run-b', delta: 'Beta progress' })
|
||||
}, runB.session_id)
|
||||
|
||||
await expect(pageA.getByText('Alpha progress')).toBeVisible()
|
||||
await expect(pageA.getByText('Beta progress')).toHaveCount(0)
|
||||
await expect(pageB.getByText('Beta progress')).toBeVisible()
|
||||
await expect(pageB.getByText('Alpha progress')).toHaveCount(0)
|
||||
expect(apiA.unexpectedRequests).toEqual([])
|
||||
expect(apiB.unexpectedRequests).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,858 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { authenticate, mockChatSocket, mockHermesApi, TEST_ACCESS_KEY } from './fixtures'
|
||||
|
||||
const inputPlaceholder = 'Type a message... (Enter to send, Shift+Enter for new line)'
|
||||
|
||||
async function sendChatMessage(page: Page, message: string) {
|
||||
const input = page.getByPlaceholder(inputPlaceholder)
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill(message)
|
||||
await page.getByRole('button', { name: 'Send' }).click()
|
||||
}
|
||||
|
||||
async function waitForRun(page: Page, index = 0) {
|
||||
const handle = await page.waitForFunction((runIndex) => {
|
||||
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<any>
|
||||
}
|
||||
|
||||
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')
|
||||
|
||||
await sendChatMessage(page, 'Summarize the queue')
|
||||
|
||||
await expect(page.locator('p').filter({ hasText: /^Summarize the queue$/ })).toBeVisible()
|
||||
|
||||
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).toMatchObject({
|
||||
input: 'Summarize the queue',
|
||||
queue_id: expect.any(String),
|
||||
session_id: expect.any(String),
|
||||
source: 'cli',
|
||||
})
|
||||
expect(run.model).toBe('test-model')
|
||||
|
||||
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,
|
||||
})
|
||||
}, run.session_id)
|
||||
|
||||
await expect(page.getByText('Streaming answer from Hermes')).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.getByTestId('profile-selector-select').click()
|
||||
await expect(page.getByRole('dialog').filter({ hasText: 'research' })).toBeVisible()
|
||||
const reloadPromise = page.waitForEvent('framenavigated', frame => frame === page.mainFrame())
|
||||
await page.locator('.profile-runtime-item').filter({ hasText: /^research/ }).getByRole('button', { name: 'Switch Frontend Profile' }).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')
|
||||
|
||||
expect(api.requests.some((request) => request.pathname === '/api/hermes/profiles/active')).toBe(false)
|
||||
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 expect(page.locator('p').filter({ hasText: /^Second queued contract$/ })).toHaveCount(0)
|
||||
|
||||
await page.evaluate(({ sid, queueId }) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('run.peer_user_message', {
|
||||
event: 'run.peer_user_message',
|
||||
session_id: sid,
|
||||
message: {
|
||||
id: queueId,
|
||||
role: 'user',
|
||||
content: 'Second queued contract',
|
||||
timestamp: Date.now() / 1000,
|
||||
},
|
||||
})
|
||||
}, { sid: first.run.session_id, queueId: second.run.queue_id })
|
||||
await expect(page.locator('p').filter({ hasText: /^Second queued contract$/ })).toHaveCount(0)
|
||||
|
||||
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,
|
||||
})
|
||||
}, first.run.session_id)
|
||||
|
||||
await expect(page.locator('p').filter({ hasText: /^Second queued contract$/ })).toHaveCount(0)
|
||||
|
||||
await page.evaluate(({ sid, queueId }) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('run.queued', {
|
||||
event: 'run.queued',
|
||||
session_id: sid,
|
||||
queue_length: 0,
|
||||
dequeued_queue_id: queueId,
|
||||
queued_messages: [],
|
||||
})
|
||||
socket.__trigger('run.peer_user_message', {
|
||||
event: 'run.peer_user_message',
|
||||
session_id: sid,
|
||||
message: {
|
||||
id: queueId,
|
||||
role: 'user',
|
||||
content: 'Second queued contract',
|
||||
timestamp: Date.now() / 1000,
|
||||
},
|
||||
})
|
||||
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,
|
||||
})
|
||||
}, { sid: first.run.session_id, queueId: second.run.queue_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('clears previous compression status when a new run starts', 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, 'Trigger compression before answering')
|
||||
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' })
|
||||
socket.__trigger('compression.completed', {
|
||||
event: 'compression.completed',
|
||||
session_id: sid,
|
||||
totalMessages: 12,
|
||||
beforeTokens: 24000,
|
||||
afterTokens: 6000,
|
||||
compressed: true,
|
||||
})
|
||||
}, first.run.session_id)
|
||||
|
||||
await expect(page.getByText(/Compressed 12 msgs/)).toBeVisible()
|
||||
|
||||
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',
|
||||
})
|
||||
}, first.run.session_id)
|
||||
|
||||
await sendChatMessage(page, 'Start another turn')
|
||||
const second = await waitForRun(page, 1)
|
||||
|
||||
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-2' })
|
||||
}, second.run.session_id)
|
||||
|
||||
await expect(page.getByText(/Compressed 12 msgs/)).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([])
|
||||
})
|
||||
|
||||
test('renders tool trace and sends explicit approval decisions over the chat-run socket', 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, 'Use write_file with approval')
|
||||
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-approval' })
|
||||
socket.__trigger('tool.started', {
|
||||
event: 'tool.started',
|
||||
session_id: sid,
|
||||
run_id: 'run-approval',
|
||||
tool_call_id: 'tool-call-1',
|
||||
tool: 'write_file',
|
||||
preview: 'Writing approved file',
|
||||
arguments: JSON.stringify({ path: '/tmp/approved.txt', content: 'hello' }),
|
||||
})
|
||||
socket.__trigger('approval.requested', {
|
||||
event: 'approval.requested',
|
||||
session_id: sid,
|
||||
run_id: 'run-approval',
|
||||
approval_id: 'approval-1',
|
||||
command: 'write_file /tmp/approved.txt',
|
||||
description: 'Allow write_file to create /tmp/approved.txt',
|
||||
choices: ['once', 'deny'],
|
||||
allow_permanent: false,
|
||||
})
|
||||
}, run.session_id)
|
||||
|
||||
await expect(page.getByText('write_file', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('Writing approved file')).toBeVisible()
|
||||
await expect(page.locator('.message.tool .tool-line')).toHaveCount(0)
|
||||
await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'write_file' })).toBeVisible()
|
||||
await expect(page.getByText('Allow write_file to create /tmp/approved.txt')).toBeVisible()
|
||||
await expect(page.getByText('write_file /tmp/approved.txt')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Allow once' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Allow session' })).toHaveCount(0)
|
||||
await expect(page.getByRole('button', { name: 'Deny' })).toBeVisible()
|
||||
|
||||
await page.evaluate((sid) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('approval.resolved', {
|
||||
event: 'approval.resolved',
|
||||
session_id: sid,
|
||||
run_id: 'run-approval',
|
||||
approval_id: 'approval-other',
|
||||
choice: 'deny',
|
||||
resolved: true,
|
||||
})
|
||||
}, run.session_id)
|
||||
await expect(page.getByText('Allow write_file to create /tmp/approved.txt')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Allow once' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Allow once' }).click()
|
||||
|
||||
await expect(page.getByText('Allow write_file to create /tmp/approved.txt')).toHaveCount(0)
|
||||
await expect(page.getByRole('button', { name: 'Allow once' })).toHaveCount(0)
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const emitted = (window as any).__PW_CHAT_SOCKET__.emitted
|
||||
return emitted.filter((item: any) => item.event === 'approval.respond')
|
||||
})).toEqual([
|
||||
{
|
||||
event: 'approval.respond',
|
||||
payload: {
|
||||
session_id: run.session_id,
|
||||
approval_id: 'approval-1',
|
||||
choice: 'once',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
await page.evaluate((sid) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('approval.resolved', {
|
||||
event: 'approval.resolved',
|
||||
session_id: sid,
|
||||
run_id: 'run-approval',
|
||||
approval_id: 'approval-1',
|
||||
choice: 'once',
|
||||
resolved: true,
|
||||
})
|
||||
socket.__trigger('tool.completed', {
|
||||
event: 'tool.completed',
|
||||
session_id: sid,
|
||||
run_id: 'run-approval',
|
||||
tool_call_id: 'tool-call-1',
|
||||
tool: 'write_file',
|
||||
output: JSON.stringify({ ok: true, path: '/tmp/approved.txt' }),
|
||||
duration: 42,
|
||||
})
|
||||
socket.__trigger('message.delta', {
|
||||
event: 'message.delta',
|
||||
session_id: sid,
|
||||
run_id: 'run-approval',
|
||||
delta: 'Delta-only approved tool result.',
|
||||
})
|
||||
socket.__trigger('run.completed', {
|
||||
event: 'run.completed',
|
||||
session_id: sid,
|
||||
run_id: 'run-approval',
|
||||
output: 'Completion fallback should stay hidden.',
|
||||
})
|
||||
}, run.session_id)
|
||||
|
||||
const persistedToolTrace = page.locator('.message.tool .tool-line').filter({ hasText: 'write_file' })
|
||||
await expect(persistedToolTrace).toHaveCount(1)
|
||||
await persistedToolTrace.click()
|
||||
const toolDetails = page.locator('.message.tool .tool-details')
|
||||
await expect(toolDetails).toContainText('/tmp/approved.txt')
|
||||
await expect(toolDetails).toContainText('ok')
|
||||
await expect(page.getByText('Delta-only approved tool result.')).toBeVisible()
|
||||
await expect(page.getByText('Completion fallback should stay hidden.')).toHaveCount(0)
|
||||
await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'write_file' })).toHaveCount(0)
|
||||
await expect(page.getByRole('button', { name: 'Stop' })).toHaveCount(0)
|
||||
expect(api.unexpectedRequests).toEqual([])
|
||||
})
|
||||
|
||||
test('keeps prior tool trace visible while hiding only the active run tool trace', 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 tool trace')
|
||||
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-history-1' })
|
||||
socket.__trigger('tool.started', {
|
||||
event: 'tool.started',
|
||||
session_id: sid,
|
||||
run_id: 'run-history-1',
|
||||
tool_call_id: 'tool-history-1',
|
||||
tool: 'read_file',
|
||||
preview: 'Read historical file',
|
||||
arguments: JSON.stringify({ path: '/tmp/history.txt' }),
|
||||
})
|
||||
socket.__trigger('tool.completed', {
|
||||
event: 'tool.completed',
|
||||
session_id: sid,
|
||||
run_id: 'run-history-1',
|
||||
tool_call_id: 'tool-history-1',
|
||||
tool: 'read_file',
|
||||
output: JSON.stringify({ ok: true, path: '/tmp/history.txt' }),
|
||||
duration: 12,
|
||||
})
|
||||
socket.__trigger('message.delta', {
|
||||
event: 'message.delta',
|
||||
session_id: sid,
|
||||
run_id: 'run-history-1',
|
||||
delta: 'First tool answer.',
|
||||
})
|
||||
socket.__trigger('run.completed', {
|
||||
event: 'run.completed',
|
||||
session_id: sid,
|
||||
run_id: 'run-history-1',
|
||||
output: 'First fallback should stay hidden.',
|
||||
})
|
||||
}, first.run.session_id)
|
||||
|
||||
const transcriptTools = page.locator('.message.tool .tool-line')
|
||||
await expect(transcriptTools.filter({ hasText: 'read_file' })).toHaveCount(1)
|
||||
await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'read_file' })).toHaveCount(0)
|
||||
|
||||
await sendChatMessage(page, 'Second tool trace')
|
||||
const second = await waitForRun(page, 1)
|
||||
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-history-2' })
|
||||
socket.__trigger('tool.started', {
|
||||
event: 'tool.started',
|
||||
session_id: sid,
|
||||
run_id: 'run-history-2',
|
||||
tool_call_id: 'tool-history-2',
|
||||
tool: 'write_file',
|
||||
preview: 'Write current file',
|
||||
arguments: JSON.stringify({ path: '/tmp/current.txt', content: 'now' }),
|
||||
})
|
||||
}, second.run.session_id)
|
||||
|
||||
await expect(transcriptTools.filter({ hasText: 'read_file' })).toHaveCount(1)
|
||||
await expect(transcriptTools.filter({ hasText: 'write_file' })).toHaveCount(0)
|
||||
await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'read_file' })).toHaveCount(0)
|
||||
await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'write_file' })).toHaveCount(1)
|
||||
|
||||
await page.evaluate((sid) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('tool.completed', {
|
||||
event: 'tool.completed',
|
||||
session_id: sid,
|
||||
run_id: 'run-history-2',
|
||||
tool_call_id: 'tool-history-2',
|
||||
tool: 'write_file',
|
||||
output: JSON.stringify({ ok: true, path: '/tmp/current.txt' }),
|
||||
duration: 15,
|
||||
})
|
||||
socket.__trigger('message.delta', {
|
||||
event: 'message.delta',
|
||||
session_id: sid,
|
||||
run_id: 'run-history-2',
|
||||
delta: 'Second tool answer.',
|
||||
})
|
||||
socket.__trigger('run.completed', {
|
||||
event: 'run.completed',
|
||||
session_id: sid,
|
||||
run_id: 'run-history-2',
|
||||
output: 'Second fallback should stay hidden.',
|
||||
})
|
||||
}, second.run.session_id)
|
||||
|
||||
await expect(transcriptTools).toHaveCount(2)
|
||||
await expect(transcriptTools.filter({ hasText: 'read_file' })).toHaveCount(1)
|
||||
await expect(transcriptTools.filter({ hasText: 'write_file' })).toHaveCount(1)
|
||||
await expect(page.getByText('First fallback should stay hidden.')).toHaveCount(0)
|
||||
await expect(page.getByText('Second fallback should stay hidden.')).toHaveCount(0)
|
||||
await expect(page.getByRole('button', { name: 'Stop' })).toHaveCount(0)
|
||||
expect(api.unexpectedRequests).toEqual([])
|
||||
})
|
||||
|
||||
test('keeps completed same-run tool traces hidden until the run finishes', 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, 'Run multiple tools')
|
||||
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-multi-tool' })
|
||||
socket.__trigger('tool.started', {
|
||||
event: 'tool.started',
|
||||
session_id: sid,
|
||||
run_id: 'run-multi-tool',
|
||||
tool_call_id: 'tool-multi-1',
|
||||
tool: 'read_file',
|
||||
preview: 'Read config',
|
||||
arguments: JSON.stringify({ path: '/tmp/config.json' }),
|
||||
})
|
||||
socket.__trigger('tool.started', {
|
||||
event: 'tool.started',
|
||||
session_id: sid,
|
||||
run_id: 'run-multi-tool',
|
||||
tool_call_id: 'tool-multi-2',
|
||||
tool: 'shell_exec',
|
||||
preview: 'Run command',
|
||||
arguments: JSON.stringify({ command: 'false' }),
|
||||
})
|
||||
}, run.session_id)
|
||||
|
||||
const transcriptTools = page.locator('.message.tool .tool-line')
|
||||
await expect(transcriptTools).toHaveCount(0)
|
||||
await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'read_file' })).toHaveCount(1)
|
||||
await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'shell_exec' })).toHaveCount(1)
|
||||
|
||||
await page.evaluate((sid) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('tool.completed', {
|
||||
event: 'tool.completed',
|
||||
session_id: sid,
|
||||
run_id: 'run-multi-tool',
|
||||
tool_call_id: 'tool-multi-1',
|
||||
tool: 'read_file',
|
||||
output: JSON.stringify({ ok: true, path: '/tmp/config.json' }),
|
||||
duration: 11,
|
||||
})
|
||||
socket.__trigger('tool.completed', {
|
||||
event: 'tool.completed',
|
||||
session_id: sid,
|
||||
run_id: 'run-multi-tool',
|
||||
tool_call_id: 'tool-multi-2',
|
||||
tool: 'shell_exec',
|
||||
output: 'exit status 1',
|
||||
error: true,
|
||||
duration: 13,
|
||||
})
|
||||
}, run.session_id)
|
||||
|
||||
await expect(transcriptTools).toHaveCount(0)
|
||||
await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'read_file' })).toHaveCount(1)
|
||||
await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'shell_exec' })).toHaveCount(1)
|
||||
|
||||
await page.evaluate((sid) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('message.delta', {
|
||||
event: 'message.delta',
|
||||
session_id: sid,
|
||||
run_id: 'run-multi-tool',
|
||||
delta: 'Multiple tools finished.',
|
||||
})
|
||||
socket.__trigger('run.completed', {
|
||||
event: 'run.completed',
|
||||
session_id: sid,
|
||||
run_id: 'run-multi-tool',
|
||||
output: 'Multi-tool fallback should stay hidden.',
|
||||
})
|
||||
}, run.session_id)
|
||||
|
||||
await expect(transcriptTools).toHaveCount(2)
|
||||
await expect(transcriptTools.filter({ hasText: 'read_file' })).toHaveCount(1)
|
||||
await expect(transcriptTools.filter({ hasText: 'shell_exec' })).toHaveCount(1)
|
||||
await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'read_file' })).toHaveCount(0)
|
||||
await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'shell_exec' })).toHaveCount(0)
|
||||
await expect(page.locator('.message.tool .tool-error-badge')).toHaveCount(1)
|
||||
await transcriptTools.filter({ hasText: 'shell_exec' }).click()
|
||||
await expect(page.locator('.message.tool .tool-details')).toContainText('exit status 1')
|
||||
await expect(page.getByText('Multi-tool fallback should stay hidden.')).toHaveCount(0)
|
||||
await expect(page.getByRole('button', { name: 'Stop' })).toHaveCount(0)
|
||||
expect(api.unexpectedRequests).toEqual([])
|
||||
})
|
||||
|
||||
test('keeps unnamed tool trace messages out of the transcript after completion', 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, 'Run internal unnamed tool')
|
||||
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-unnamed-tool' })
|
||||
socket.__trigger('tool.started', {
|
||||
event: 'tool.started',
|
||||
session_id: sid,
|
||||
run_id: 'run-unnamed-tool',
|
||||
tool_call_id: 'tool-unnamed-1',
|
||||
preview: 'Internal unnamed work',
|
||||
arguments: JSON.stringify({ internal: true }),
|
||||
})
|
||||
socket.__trigger('tool.completed', {
|
||||
event: 'tool.completed',
|
||||
session_id: sid,
|
||||
run_id: 'run-unnamed-tool',
|
||||
tool_call_id: 'tool-unnamed-1',
|
||||
output: JSON.stringify({ internal: true, ok: true }),
|
||||
duration: 9,
|
||||
})
|
||||
socket.__trigger('message.delta', {
|
||||
event: 'message.delta',
|
||||
session_id: sid,
|
||||
run_id: 'run-unnamed-tool',
|
||||
delta: 'Unnamed internal tool finished.',
|
||||
})
|
||||
socket.__trigger('run.completed', {
|
||||
event: 'run.completed',
|
||||
session_id: sid,
|
||||
run_id: 'run-unnamed-tool',
|
||||
output: 'Unnamed fallback should stay hidden.',
|
||||
})
|
||||
}, run.session_id)
|
||||
|
||||
await expect(page.locator('.message.tool .tool-line')).toHaveCount(0)
|
||||
await expect(page.getByText('Unnamed internal tool finished.')).toBeVisible()
|
||||
await expect(page.getByText('Unnamed fallback should stay hidden.')).toHaveCount(0)
|
||||
await expect(page.getByRole('button', { name: 'Stop' })).toHaveCount(0)
|
||||
expect(api.unexpectedRequests).toEqual([])
|
||||
})
|
||||
|
||||
test('keeps unnamed resumed tool traces hidden after session reload', async ({ page }) => {
|
||||
const sessionId = 'session-history-unnamed-tool'
|
||||
const sessionSummary = {
|
||||
id: sessionId,
|
||||
source: 'api_server',
|
||||
model: 'test-model',
|
||||
title: 'Unnamed tool history',
|
||||
preview: 'History answer visible.',
|
||||
started_at: 1,
|
||||
ended_at: 4,
|
||||
last_active: 4,
|
||||
message_count: 4,
|
||||
tool_call_count: 1,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: 'test-provider',
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: null,
|
||||
cost_status: 'none',
|
||||
workspace: null,
|
||||
}
|
||||
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||
await page.addInitScript((sid) => {
|
||||
;(window as any).__PW_CHAT_SOCKET_RESUMES__ = {
|
||||
[sid]: {
|
||||
session_id: sid,
|
||||
isWorking: false,
|
||||
events: [],
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
session_id: sid,
|
||||
role: 'user',
|
||||
content: 'Resume unnamed internal tool',
|
||||
tool_call_id: null,
|
||||
tool_calls: null,
|
||||
tool_name: null,
|
||||
timestamp: 1,
|
||||
token_count: null,
|
||||
finish_reason: null,
|
||||
reasoning: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
session_id: sid,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_call_id: null,
|
||||
tool_calls: [{ id: 'tool-resume-unnamed-1', type: 'function', function: { arguments: JSON.stringify({ internal: true }) } }],
|
||||
tool_name: null,
|
||||
timestamp: 2,
|
||||
token_count: null,
|
||||
finish_reason: 'tool_calls',
|
||||
reasoning: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
session_id: sid,
|
||||
role: 'tool',
|
||||
content: JSON.stringify({ internal: true, ok: true }),
|
||||
tool_call_id: 'tool-resume-unnamed-1',
|
||||
tool_calls: null,
|
||||
tool_name: null,
|
||||
timestamp: 3,
|
||||
token_count: null,
|
||||
finish_reason: null,
|
||||
reasoning: null,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
session_id: sid,
|
||||
role: 'assistant',
|
||||
content: 'History answer visible.',
|
||||
tool_call_id: null,
|
||||
tool_calls: null,
|
||||
tool_name: null,
|
||||
timestamp: 4,
|
||||
token_count: null,
|
||||
finish_reason: 'stop',
|
||||
reasoning: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}, sessionId)
|
||||
const api = await mockHermesApi(page, { sessions: [sessionSummary] })
|
||||
await mockChatSocket(page)
|
||||
|
||||
await page.goto('/#/hermes/chat')
|
||||
|
||||
await expect(page.getByText('History answer visible.')).toBeVisible()
|
||||
await expect(page.locator('.message.tool .tool-line')).toHaveCount(0)
|
||||
await expect(page.locator('.message.tool')).toHaveCount(0)
|
||||
const resumeRequest = await page.waitForFunction((sid) => {
|
||||
const state = (window as any).__PW_CHAT_SOCKET__
|
||||
return state?.emitted?.some((item: any) => item.event === 'resume' && item.payload?.session_id === sid)
|
||||
}, sessionId)
|
||||
expect(await resumeRequest.jsonValue()).toBe(true)
|
||||
expect(api.unexpectedRequests).toEqual([])
|
||||
})
|
||||
|
||||
test('restores named resumed tool traces from assistant tool calls after session reload', async ({ page }) => {
|
||||
const sessionId = 'session-history-named-tool'
|
||||
const sessionSummary = {
|
||||
id: sessionId,
|
||||
source: 'api_server',
|
||||
model: 'test-model',
|
||||
title: 'Named tool history',
|
||||
preview: 'Named history answer visible.',
|
||||
started_at: 1,
|
||||
ended_at: 4,
|
||||
last_active: 4,
|
||||
message_count: 4,
|
||||
tool_call_count: 1,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: 'test-provider',
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: null,
|
||||
cost_status: 'none',
|
||||
workspace: null,
|
||||
}
|
||||
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||
await page.addInitScript((sid) => {
|
||||
;(window as any).__PW_CHAT_SOCKET_RESUMES__ = {
|
||||
[sid]: {
|
||||
session_id: sid,
|
||||
isWorking: false,
|
||||
events: [],
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
session_id: sid,
|
||||
role: 'user',
|
||||
content: 'Resume named tool',
|
||||
tool_call_id: null,
|
||||
tool_calls: null,
|
||||
tool_name: null,
|
||||
timestamp: 1,
|
||||
token_count: null,
|
||||
finish_reason: null,
|
||||
reasoning: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
session_id: sid,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_call_id: null,
|
||||
tool_calls: [{ id: 'tool-resume-named-1', type: 'function', function: { name: 'read_file', arguments: JSON.stringify({ path: '/tmp/history.txt' }) } }],
|
||||
tool_name: null,
|
||||
timestamp: 2,
|
||||
token_count: null,
|
||||
finish_reason: 'tool_calls',
|
||||
reasoning: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
session_id: sid,
|
||||
role: 'tool',
|
||||
content: JSON.stringify({ ok: true, path: '/tmp/history.txt' }),
|
||||
tool_call_id: 'tool-resume-named-1',
|
||||
tool_calls: null,
|
||||
tool_name: null,
|
||||
timestamp: 3,
|
||||
token_count: null,
|
||||
finish_reason: null,
|
||||
reasoning: null,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
session_id: sid,
|
||||
role: 'assistant',
|
||||
content: 'Named history answer visible.',
|
||||
tool_call_id: null,
|
||||
tool_calls: null,
|
||||
tool_name: null,
|
||||
timestamp: 4,
|
||||
token_count: null,
|
||||
finish_reason: 'stop',
|
||||
reasoning: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}, sessionId)
|
||||
const api = await mockHermesApi(page, { sessions: [sessionSummary] })
|
||||
await mockChatSocket(page)
|
||||
|
||||
await page.goto('/#/hermes/chat')
|
||||
|
||||
await expect(page.getByText('Named history answer visible.')).toBeVisible()
|
||||
const restoredTrace = page.locator('.message.tool .tool-line').filter({ hasText: 'read_file' })
|
||||
await expect(restoredTrace).toHaveCount(1)
|
||||
await restoredTrace.click()
|
||||
await expect(page.locator('.message.tool .tool-details')).toContainText('/tmp/history.txt')
|
||||
expect(api.unexpectedRequests).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,415 @@
|
||||
import type { Page, Request, Route } from '@playwright/test'
|
||||
|
||||
export const TEST_ACCESS_KEY = 'playwright-access-key'
|
||||
|
||||
export interface MockedRequest {
|
||||
method: string
|
||||
pathname: string
|
||||
search: string
|
||||
headers: Record<string, string>
|
||||
postData: string | null
|
||||
}
|
||||
|
||||
interface MockHermesApiOptions {
|
||||
tokenValidationStatus?: number
|
||||
initialProfileName?: 'default' | 'research'
|
||||
sessions?: unknown[]
|
||||
}
|
||||
|
||||
const sampleModelGroup = {
|
||||
provider: 'test-provider',
|
||||
label: 'Test Provider',
|
||||
base_url: 'https://example.invalid/v1',
|
||||
models: ['test-model'],
|
||||
available_models: ['test-model'],
|
||||
api_key: '',
|
||||
builtin: true,
|
||||
}
|
||||
|
||||
const sampleJob = {
|
||||
job_id: 'job-smoke',
|
||||
id: 'job-smoke',
|
||||
name: 'Nightly Smoke',
|
||||
prompt: 'Run the smoke check',
|
||||
prompt_preview: 'Run the smoke check',
|
||||
skills: [],
|
||||
skill: null,
|
||||
model: 'test-model',
|
||||
provider: 'test-provider',
|
||||
base_url: null,
|
||||
script: null,
|
||||
schedule: '0 9 * * *',
|
||||
schedule_display: '0 9 * * *',
|
||||
repeat: { times: null, completed: 0 },
|
||||
enabled: true,
|
||||
state: 'scheduled',
|
||||
paused_at: null,
|
||||
paused_reason: null,
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
next_run_at: '2026-01-02T09:00:00.000Z',
|
||||
last_run_at: null,
|
||||
last_status: null,
|
||||
last_error: null,
|
||||
deliver: 'origin',
|
||||
origin: null,
|
||||
last_delivery_error: null,
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return {
|
||||
status,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
}
|
||||
|
||||
function recordRequest(request: Request): MockedRequest {
|
||||
const url = new URL(request.url())
|
||||
return {
|
||||
method: request.method(),
|
||||
pathname: url.pathname,
|
||||
search: url.search,
|
||||
headers: request.headers(),
|
||||
postData: request.postData(),
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
const url = new URL(request.url())
|
||||
const { pathname } = url
|
||||
|
||||
if (!(pathname === '/health' || pathname.startsWith('/api/') || pathname.startsWith('/v1/'))) {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
|
||||
requests.push(recordRequest(request))
|
||||
|
||||
if (pathname === '/health') {
|
||||
await route.fulfill(jsonResponse({ status: 'ok', webui_version: '0.5.23', node_version: '23.0.0' }))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/auth/status') {
|
||||
await route.fulfill(jsonResponse({ hasPasswordLogin: true, username: 'playwright' }))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/auth/login') {
|
||||
if (request.method() !== 'POST') {
|
||||
await route.fulfill(jsonResponse({ error: 'Method not allowed' }, 405))
|
||||
return
|
||||
}
|
||||
if (tokenValidationStatus !== 200) {
|
||||
await route.fulfill(jsonResponse({ error: 'Invalid username or password' }, tokenValidationStatus))
|
||||
return
|
||||
}
|
||||
await route.fulfill(jsonResponse({ token: TEST_ACCESS_KEY }))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/auth/me') {
|
||||
await route.fulfill(jsonResponse({
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'playwright',
|
||||
role: 'super_admin',
|
||||
status: 'active',
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
last_login_at: 0,
|
||||
},
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/hermes/sessions') {
|
||||
await route.fulfill(jsonResponse({ sessions: options.sessions ?? [] }, tokenValidationStatus))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/hermes/sessions/hermes') {
|
||||
await route.fulfill(jsonResponse({ sessions: [] }))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/hermes/sessions/context-length') {
|
||||
await route.fulfill(jsonResponse({ context_length: 256000 }))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/hermes/files/list') {
|
||||
await route.fulfill(jsonResponse({ entries: [], path: '' }))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/hermes/auth/copilot/check-token') {
|
||||
await route.fulfill(jsonResponse({ has_token: false, source: null, enabled: false }))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/auth/locked-ips') {
|
||||
await route.fulfill(jsonResponse({ locks: [] }))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/hermes/available-models') {
|
||||
await route.fulfill(jsonResponse({
|
||||
default: 'test-model',
|
||||
default_provider: 'test-provider',
|
||||
groups: [sampleModelGroup],
|
||||
allProviders: [sampleModelGroup],
|
||||
model_aliases: {},
|
||||
model_visibility: {},
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/hermes/provider-models') {
|
||||
await route.fulfill(jsonResponse({ models: ['proxy-model-a', 'proxy-model-b'] }))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/hermes/profiles') {
|
||||
await route.fulfill(jsonResponse({
|
||||
profiles: [
|
||||
{ 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/runtime-statuses') {
|
||||
await route.fulfill(jsonResponse({
|
||||
profiles: [
|
||||
{
|
||||
profile: 'default',
|
||||
bridge: { running: activeProfileName === 'default', profile: 'default', reachable: true },
|
||||
gateway: { running: true, profile: 'default' },
|
||||
},
|
||||
{
|
||||
profile: 'research',
|
||||
bridge: { running: activeProfileName === 'research', profile: 'research', reachable: true },
|
||||
gateway: { running: true, profile: '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 },
|
||||
agent: {},
|
||||
memory: {},
|
||||
session_reset: {},
|
||||
privacy: {},
|
||||
approvals: {},
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/hermes/jobs') {
|
||||
await route.fulfill(jsonResponse({ jobs: [sampleJob] }))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/cron-history') {
|
||||
await route.fulfill(jsonResponse({ runs: [] }))
|
||||
return
|
||||
}
|
||||
|
||||
unexpectedRequests.push(recordRequest(request))
|
||||
await route.fulfill(jsonResponse({ error: `Unexpected mocked route: ${request.method()} ${pathname}` }, 404))
|
||||
})
|
||||
|
||||
return { requests, unexpectedRequests }
|
||||
}
|
||||
|
||||
export async function authenticate(page: Page, accessKey = TEST_ACCESS_KEY, profileName?: string) {
|
||||
await page.addInitScript((state: { storedToken: string; storedProfileName?: string }) => {
|
||||
const { storedToken, storedProfileName } = state
|
||||
window.localStorage.setItem('hermes_api_key', storedToken)
|
||||
if (storedProfileName && !window.localStorage.getItem('hermes_active_profile_name')) {
|
||||
window.localStorage.setItem('hermes_active_profile_name', storedProfileName)
|
||||
}
|
||||
}, { 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 })
|
||||
if (event === 'resume') {
|
||||
const sessionId = payload && payload.session_id
|
||||
const resumes = window.__PW_CHAT_SOCKET_RESUMES__ || {}
|
||||
const response = sessionId ? resumes[sessionId] : null
|
||||
if (response) {
|
||||
setTimeout(() => this.__trigger('resumed', response), 0)
|
||||
}
|
||||
}
|
||||
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 }
|
||||
`,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test'
|
||||
import { authenticate } from './fixtures'
|
||||
|
||||
const rooms = [
|
||||
{ id: 'room-alpha', name: 'Alpha Room', inviteCode: 'ALPHA1', triggerTokens: 100000, maxHistoryTokens: 32000, tailMessageCount: 10, totalTokens: 123 },
|
||||
{ id: 'room-beta', name: 'Beta Room', inviteCode: 'BETA22', triggerTokens: 100000, maxHistoryTokens: 32000, tailMessageCount: 10, totalTokens: 456 },
|
||||
]
|
||||
|
||||
const messagesByRoom: Record<string, unknown[]> = {
|
||||
'room-alpha': [
|
||||
{ id: 'alpha-msg', roomId: 'room-alpha', senderId: 'user-1', senderName: 'Alice', content: 'Alpha room message', timestamp: 1_790_000_000, role: 'user' },
|
||||
],
|
||||
'room-beta': [
|
||||
{ id: 'beta-msg', roomId: 'room-beta', senderId: 'user-1', senderName: 'Bob', content: 'Beta room message', timestamp: 1_790_000_100, role: 'user' },
|
||||
],
|
||||
}
|
||||
|
||||
async function mockGroupChatApi(page: Page) {
|
||||
await page.route('**/*', async (route: Route) => {
|
||||
const request = route.request()
|
||||
const url = new URL(request.url())
|
||||
const { pathname } = url
|
||||
|
||||
if (!(pathname === '/health' || pathname.startsWith('/api/'))) {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
|
||||
const json = (body: unknown, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
|
||||
if (pathname === '/health') return json({ status: 'ok' })
|
||||
if (pathname === '/api/auth/status') return json({ hasPasswordLogin: false, username: null })
|
||||
if (pathname === '/api/hermes/profiles') return json({ profiles: [{ name: 'default', active: true, model: 'test-model', gateway: 'test' }] })
|
||||
if (pathname === '/api/hermes/group-chat/rooms') return json({ rooms })
|
||||
|
||||
const detailMatch = pathname.match(/^\/api\/hermes\/group-chat\/rooms\/([^/]+)$/)
|
||||
if (detailMatch) {
|
||||
const roomId = decodeURIComponent(detailMatch[1])
|
||||
const room = rooms.find(r => r.id === roomId)
|
||||
return room
|
||||
? json({ room, messages: messagesByRoom[roomId] || [], agents: [], members: [{ id: 'member-1', userId: 'user-1', name: 'User One', description: '', joinedAt: 1_790_000_000 }] })
|
||||
: json({ error: 'Room not found' }, 404)
|
||||
}
|
||||
|
||||
return json({ error: `Unexpected mocked route: ${request.method()} ${pathname}` }, 404)
|
||||
})
|
||||
}
|
||||
|
||||
async function mockGroupChatSocket(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_GROUP_SOCKET__ || (window.__PW_GROUP_SOCKET__ = { sockets: [], emitted: [] })
|
||||
const roomMessages = ${JSON.stringify(messagesByRoom)}
|
||||
function makeSocket(url, options) {
|
||||
const listeners = 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
|
||||
},
|
||||
emit(event, payload, ack) {
|
||||
state.emitted.push({ event, payload })
|
||||
if (event === 'join' && typeof ack === 'function') {
|
||||
const roomId = payload && payload.roomId
|
||||
setTimeout(() => ack({ roomId, roomName: roomId, members: [], messages: roomMessages[roomId] || [], agents: [], rooms: [], typingUsers: [], contextStatuses: [] }), 0)
|
||||
}
|
||||
if (event === 'message' && typeof ack === 'function') {
|
||||
setTimeout(() => ack({ id: payload && payload.id }), 0)
|
||||
}
|
||||
return this
|
||||
},
|
||||
removeAllListeners() {
|
||||
listeners.clear()
|
||||
return this
|
||||
},
|
||||
disconnect() {
|
||||
this.connected = false
|
||||
return this
|
||||
},
|
||||
__trigger(event, payload) {
|
||||
for (const handler of listeners.get(event) || []) handler(payload)
|
||||
},
|
||||
}
|
||||
state.sockets.push(socket)
|
||||
state.latest = socket
|
||||
return socket
|
||||
}
|
||||
export function io(url, options) {
|
||||
return makeSocket(url, options)
|
||||
}
|
||||
export default { io }
|
||||
`,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function setup(page: Page, path: string) {
|
||||
await authenticate(page)
|
||||
await mockGroupChatSocket(page)
|
||||
await mockGroupChatApi(page)
|
||||
await page.goto(path)
|
||||
}
|
||||
|
||||
test.describe('group chat room deep links', () => {
|
||||
test('route room id opens selected room', async ({ page }) => {
|
||||
await setup(page, '/#/hermes/group-chat/room/room-beta')
|
||||
|
||||
await expect(page.locator('.room-title-text', { hasText: 'Beta Room' })).toBeVisible()
|
||||
await expect(page.getByText('Beta room message')).toBeVisible()
|
||||
await expect(page).toHaveURL(/#\/hermes\/group-chat\/room\/room-beta$/)
|
||||
})
|
||||
|
||||
test('clicking another room updates URL and reload preserves it', async ({ page }) => {
|
||||
await setup(page, '/#/hermes/group-chat/room/room-alpha')
|
||||
await expect(page.getByText('Alpha room message')).toBeVisible()
|
||||
|
||||
await page.getByText('Beta Room').click()
|
||||
await expect(page).toHaveURL(/#\/hermes\/group-chat\/room\/room-beta$/)
|
||||
await expect(page.getByText('Beta room message')).toBeVisible()
|
||||
|
||||
await page.reload()
|
||||
await expect(page).toHaveURL(/#\/hermes\/group-chat\/room\/room-beta$/)
|
||||
await expect(page.getByText('Beta room message')).toBeVisible()
|
||||
})
|
||||
|
||||
test('two tabs can show different rooms', async ({ context }) => {
|
||||
const first = await context.newPage()
|
||||
const second = await context.newPage()
|
||||
|
||||
await setup(first, '/#/hermes/group-chat/room/room-alpha')
|
||||
await setup(second, '/#/hermes/group-chat/room/room-beta')
|
||||
|
||||
await expect(first.getByText('Alpha room message')).toBeVisible()
|
||||
await expect(first.getByText('Beta room message')).toHaveCount(0)
|
||||
await expect(second.getByText('Beta room message')).toBeVisible()
|
||||
await expect(second.getByText('Alpha room message')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('unknown route room id falls back to base group chat route', async ({ page }) => {
|
||||
await setup(page, '/#/hermes/group-chat/room/missing-room')
|
||||
|
||||
await expect(page).toHaveURL(/#\/hermes\/group-chat$/)
|
||||
await expect(page.getByText('Alpha Room')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test'
|
||||
import { authenticate } from './fixtures'
|
||||
|
||||
const historySessions = [
|
||||
{
|
||||
id: 'hist-alpha',
|
||||
profile: 'default',
|
||||
source: 'cli',
|
||||
model: 'test-model',
|
||||
provider: 'test-provider',
|
||||
title: 'Alpha History Session',
|
||||
preview: 'Alpha preview',
|
||||
started_at: 1_790_000_000,
|
||||
ended_at: null,
|
||||
last_active: 1_790_000_100,
|
||||
message_count: 2,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 10,
|
||||
output_tokens: 20,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: null,
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: null,
|
||||
cost_status: '',
|
||||
workspace: null,
|
||||
},
|
||||
{
|
||||
id: 'hist-beta',
|
||||
profile: 'default',
|
||||
source: 'cli',
|
||||
model: 'test-model',
|
||||
provider: 'test-provider',
|
||||
title: 'Beta History Session',
|
||||
preview: 'Beta preview',
|
||||
started_at: 1_790_000_200,
|
||||
ended_at: null,
|
||||
last_active: 1_790_000_300,
|
||||
message_count: 2,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 30,
|
||||
output_tokens: 40,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: null,
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: null,
|
||||
cost_status: '',
|
||||
workspace: null,
|
||||
},
|
||||
]
|
||||
|
||||
function detailFor(id: string) {
|
||||
const session = historySessions.find(s => s.id === id)
|
||||
if (!session) return null
|
||||
return {
|
||||
...session,
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
session_id: id,
|
||||
role: 'user',
|
||||
content: `Question for ${session.title}`,
|
||||
tool_call_id: null,
|
||||
tool_calls: null,
|
||||
tool_name: null,
|
||||
timestamp: session.started_at,
|
||||
token_count: null,
|
||||
finish_reason: null,
|
||||
reasoning: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
session_id: id,
|
||||
role: 'assistant',
|
||||
content: `Answer from ${session.title}`,
|
||||
tool_call_id: null,
|
||||
tool_calls: null,
|
||||
tool_name: null,
|
||||
timestamp: session.started_at + 1,
|
||||
token_count: null,
|
||||
finish_reason: null,
|
||||
reasoning: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
async function mockHistoryApi(page: Page) {
|
||||
await page.route('**/*', async (route: Route) => {
|
||||
const request = route.request()
|
||||
const url = new URL(request.url())
|
||||
const { pathname } = url
|
||||
|
||||
if (!(pathname === '/health' || pathname.startsWith('/api/'))) {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
|
||||
const json = (body: unknown, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
|
||||
if (pathname === '/health') return json({ status: 'ok' })
|
||||
if (pathname === '/api/auth/status') return json({ hasPasswordLogin: false, username: null })
|
||||
if (pathname === '/api/hermes/available-models') return json({ default: 'test-model', default_provider: 'test-provider', groups: [], allProviders: [], model_aliases: {}, model_visibility: {} })
|
||||
if (pathname === '/api/hermes/profiles') return json({ profiles: [{ name: 'default', active: true, model: 'test-model', gateway: 'test' }] })
|
||||
if (pathname === '/api/hermes/sessions/hermes') return json({ sessions: historySessions })
|
||||
|
||||
const detailMatch = pathname.match(/^\/api\/hermes\/sessions\/hermes\/([^/]+)$/)
|
||||
if (detailMatch) {
|
||||
const detail = detailFor(decodeURIComponent(detailMatch[1]))
|
||||
return detail ? json({ session: detail }) : json({ error: 'Session not found' }, 404)
|
||||
}
|
||||
|
||||
return json({ error: `Unexpected mocked route: ${request.method()} ${pathname}` }, 404)
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('history session deep links', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await authenticate(page)
|
||||
await mockHistoryApi(page)
|
||||
})
|
||||
|
||||
test('route session id opens selected history session', async ({ page }) => {
|
||||
await page.goto('/#/hermes/history/session/hist-beta')
|
||||
|
||||
await expect(page.getByText('Beta History Session').first()).toBeVisible()
|
||||
await expect(page.getByText('Answer from Beta History Session')).toBeVisible()
|
||||
await expect(page).toHaveURL(/#\/hermes\/history\/session\/hist-beta$/)
|
||||
})
|
||||
|
||||
test('clicking another history session updates URL and reload preserves it', async ({ page }) => {
|
||||
await page.goto('/#/hermes/history/session/hist-alpha')
|
||||
await expect(page.getByText('Answer from Alpha History Session')).toBeVisible()
|
||||
|
||||
await page.getByText('Beta History Session').first().click()
|
||||
await expect(page).toHaveURL(/#\/hermes\/history\/session\/hist-beta\?profile=default$/)
|
||||
await expect(page.getByText('Answer from Beta History Session')).toBeVisible()
|
||||
|
||||
await page.reload()
|
||||
await expect(page).toHaveURL(/#\/hermes\/history\/session\/hist-beta\?profile=default$/)
|
||||
await expect(page.getByText('Answer from Beta History Session')).toBeVisible()
|
||||
})
|
||||
|
||||
test('unknown route session id falls back to base history route', async ({ page }) => {
|
||||
await page.goto('/#/hermes/history/session/missing-session')
|
||||
|
||||
await expect(page).toHaveURL(/#\/hermes\/history$/)
|
||||
await expect(page.getByText('Alpha History Session').first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { authenticate, mockHermesApi, TEST_ACCESS_KEY } from './fixtures'
|
||||
|
||||
const sampleSession = {
|
||||
id: 'session-native-1',
|
||||
title: 'Native Link Session',
|
||||
source: 'cli',
|
||||
model: 'test-model',
|
||||
provider: 'test-provider',
|
||||
profile: 'research',
|
||||
started_at: 1_700_000_000,
|
||||
ended_at: null,
|
||||
last_active: 1_700_000_100,
|
||||
message_count: 2,
|
||||
}
|
||||
|
||||
test('sidebar navigation exposes native links', async ({ page }) => {
|
||||
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||
await mockHermesApi(page)
|
||||
await page.goto('/#/hermes/chat')
|
||||
|
||||
const models = page.locator('aside.sidebar').getByRole('link', { name: /^Models$/ })
|
||||
await expect(models).toHaveAttribute('href', '#/hermes/models')
|
||||
|
||||
const history = page.locator('aside.sidebar').getByRole('link', { name: /^History$/ })
|
||||
await expect(history).toHaveAttribute('href', '#/hermes/history')
|
||||
})
|
||||
|
||||
test('session rows expose native session links', async ({ page }) => {
|
||||
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||
await mockHermesApi(page, { sessions: [sampleSession] })
|
||||
await page.goto('/#/hermes/chat')
|
||||
|
||||
const sessionLink = page.locator('.session-items a.session-item').first()
|
||||
await expect(sessionLink).toHaveAttribute('href', '#/hermes/session/session-native-1')
|
||||
await expect(sessionLink).toContainText('Native Link Session')
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { authenticate, mockHermesApi, TEST_ACCESS_KEY } from './fixtures'
|
||||
|
||||
test('fetches custom provider models through the backend proxy', async ({ page }) => {
|
||||
await authenticate(page, TEST_ACCESS_KEY)
|
||||
const api = await mockHermesApi(page)
|
||||
|
||||
const thirdPartyRequests: string[] = []
|
||||
page.on('request', (request) => {
|
||||
const url = request.url()
|
||||
if (url.startsWith('https://provider.example.test')) {
|
||||
thirdPartyRequests.push(url)
|
||||
}
|
||||
})
|
||||
|
||||
await page.goto('/#/hermes/models')
|
||||
|
||||
await page.getByRole('button', { name: 'Add Provider' }).click()
|
||||
await page.getByRole('button', { name: 'Custom' }).click()
|
||||
await page.getByPlaceholder('e.g. https://api.example.com/v1').fill('https://provider.example.test/v1')
|
||||
await page.getByPlaceholder('sk-...').fill('test-provider-key')
|
||||
await page.getByRole('button', { name: 'Fetch' }).click()
|
||||
|
||||
await expect(page.getByText('Found 2 models')).toBeVisible()
|
||||
await expect(page.getByText('proxy-model-a')).toBeVisible()
|
||||
|
||||
const proxyRequest = api.requests.find((request) => request.pathname === '/api/hermes/provider-models')
|
||||
expect(proxyRequest).toBeTruthy()
|
||||
expect(proxyRequest?.method).toBe('POST')
|
||||
expect(proxyRequest?.headers.authorization).toBe(`Bearer ${TEST_ACCESS_KEY}`)
|
||||
expect(JSON.parse(proxyRequest?.postData || '{}')).toMatchObject({
|
||||
base_url: 'https://provider.example.test/v1',
|
||||
api_key: 'test-provider-key',
|
||||
})
|
||||
expect(thirdPartyRequests).toEqual([])
|
||||
expect(api.unexpectedRequests).toEqual([])
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
Reference in New Issue
Block a user