Files
hermes-ui/tests/e2e/chat-session-multitab.spec.ts
T
toor 1e9ea89d86
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
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.
2026-10-16 10:34:35 +08:00

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([])
})