Import the client, server, skills, tests, and build scripts as the starting point for the rebuild, then trim the surfaces this project does not ship: Electron desktop packaging, the marketing website, and the ESP32 firmware tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows. Rename the package and CLI entry points to hermes-ui, drop the prepare hook so npm ci no longer triggers a build, and drop the removed tsconfig and nodemon references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs so the documented package map and hard rules match this repository. reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
170 lines
5.7 KiB
TypeScript
170 lines
5.7 KiB
TypeScript
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([])
|
|
})
|