Files
hermes-ui/tests/client/group-chat-store-baseline.test.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

339 lines
12 KiB
TypeScript

// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import type { ChatMessage, MemberInfo, RoomAgent, RoomInfo } from '@/api/hermes/group-chat'
const groupChatApiMock = vi.hoisted(() => {
const handlers = new Map<string, Function[]>()
let joinAck: any = { members: [], agents: [], typingUsers: [], contextStatuses: [] }
const socket: any = {
connected: true,
id: 'socket-1',
on: vi.fn((event: string, cb: Function) => {
const existing = handlers.get(event) || []
existing.push(cb)
handlers.set(event, existing)
return socket
}),
once: vi.fn((event: string, cb: Function) => {
const wrapped = (...args: any[]) => {
socket.off(event, wrapped)
cb(...args)
}
return socket.on(event, wrapped)
}),
off: vi.fn((event: string, cb?: Function) => {
if (!cb) {
handlers.delete(event)
return socket
}
const existing = handlers.get(event) || []
handlers.set(event, existing.filter(handler => handler !== cb))
return socket
}),
emit: vi.fn((event: string, data?: any, ack?: Function) => {
if (event === 'join' && ack) ack(joinAck)
if (event === 'message' && ack) ack({ id: data?.id })
return socket
}),
disconnect: vi.fn(),
}
return {
handlers,
socket,
setJoinAck: (value: any) => { joinAck = value },
connectGroupChat: vi.fn(() => socket),
disconnectGroupChat: vi.fn(),
getSocket: vi.fn(() => socket),
getStoredUserId: vi.fn(() => 'user-1'),
getStoredUserName: vi.fn(() => 'tester'),
createRoom: vi.fn(),
listRooms: vi.fn(),
getRoomDetail: vi.fn(),
joinRoomByCode: vi.fn(),
addAgent: vi.fn(),
listAgents: vi.fn(),
removeAgent: vi.fn(),
cloneRoom: vi.fn(),
deleteRoom: vi.fn(),
clearRoomContext: vi.fn(),
}
})
const clientApiMock = vi.hoisted(() => ({
getApiKey: vi.fn(() => 'test-token'),
getActiveProfileName: vi.fn(() => 'research'),
getStoredUsername: vi.fn(() => null),
}))
const authApiMock = vi.hoisted(() => ({
fetchCurrentUser: vi.fn(),
}))
const fetchMock = vi.hoisted(() => vi.fn())
vi.mock('@/api/hermes/group-chat', () => groupChatApiMock)
vi.mock('@/api/client', () => clientApiMock)
vi.mock('@/api/auth', () => authApiMock)
vi.mock('@/api/hermes/download', () => ({ getDownloadUrl: vi.fn((path: string) => `/download?path=${path}`) }))
vi.stubGlobal('fetch', fetchMock)
function emitSocket(event: string, payload: unknown) {
for (const cb of groupChatApiMock.handlers.get(event) || []) cb(payload)
}
const room: RoomInfo = {
id: 'room-1',
name: 'Test Room',
inviteCode: 'ROOM1',
totalTokens: 7,
} as RoomInfo
const member: MemberInfo = {
id: 'member-1',
userId: 'user-1',
name: 'tester',
joinedAt: 1,
online: true,
socketId: 'socket-1',
} as MemberInfo
const agent: RoomAgent = {
id: 'row-agent',
roomId: 'room-1',
agentId: 'agent-1',
profile: 'default',
name: 'Agent',
description: '',
invited: 0,
} as RoomAgent
function userMessage(overrides: Partial<ChatMessage> = {}): ChatMessage {
return {
id: 'msg-1',
roomId: 'room-1',
senderId: 'user-1',
senderName: 'tester',
content: 'hello',
timestamp: 1,
role: 'user',
...overrides,
}
}
async function loadStore() {
const { useGroupChatStore } = await import('@/stores/hermes/group-chat')
return useGroupChatStore()
}
describe('group chat store baseline lifecycle', () => {
beforeEach(() => {
vi.useRealTimers()
setActivePinia(createPinia())
localStorage.clear()
groupChatApiMock.handlers.clear()
groupChatApiMock.setJoinAck({ members: [], agents: [], typingUsers: [], contextStatuses: [] })
for (const key of Object.keys(groupChatApiMock)) {
const value = (groupChatApiMock as any)[key]
if (value?.mockReset && key !== 'socket') value.mockReset()
}
groupChatApiMock.connectGroupChat.mockReturnValue(groupChatApiMock.socket)
groupChatApiMock.disconnectGroupChat.mockReset()
groupChatApiMock.getSocket.mockReturnValue(groupChatApiMock.socket)
groupChatApiMock.getStoredUserId.mockReturnValue('user-1')
groupChatApiMock.getStoredUserName.mockReturnValue('Stored User')
groupChatApiMock.getRoomDetail.mockResolvedValue({ room, messages: [], agents: [], members: [], total: 0, hasMore: false })
groupChatApiMock.clearRoomContext.mockResolvedValue({ success: true, room: { ...room, totalTokens: 0 } })
clientApiMock.getApiKey.mockReturnValue('test-token')
clientApiMock.getActiveProfileName.mockReturnValue('research')
clientApiMock.getStoredUsername.mockReturnValue(null)
authApiMock.fetchCurrentUser.mockRejectedValue(new Error('not signed in'))
fetchMock.mockReset()
groupChatApiMock.socket.connected = true
groupChatApiMock.socket.on.mockClear()
groupChatApiMock.socket.once.mockClear()
groupChatApiMock.socket.off.mockClear()
groupChatApiMock.socket.emit.mockReset()
groupChatApiMock.socket.emit.mockImplementation((event: string, data?: any, ack?: Function) => {
if (event === 'join' && ack) ack({ members: [], agents: [], typingUsers: [], contextStatuses: [] })
if (event === 'message' && ack) ack({ id: data?.id })
return groupChatApiMock.socket
})
groupChatApiMock.socket.disconnect.mockClear()
})
it('connects with stored user data and registers realtime handlers', async () => {
const store = await loadStore()
await store.connect()
expect(groupChatApiMock.connectGroupChat).toHaveBeenCalledWith({
userId: 'user-1',
userName: 'Stored User',
authUserId: undefined,
})
expect(groupChatApiMock.socket.on).toHaveBeenCalledWith('message', expect.any(Function))
expect(groupChatApiMock.socket.on).toHaveBeenCalledWith('approval.requested', expect.any(Function))
expect(groupChatApiMock.socket.on).toHaveBeenCalledWith('room_cleared', expect.any(Function))
})
it('joins a room from REST detail and realtime ack state', async () => {
const store = await loadStore()
const detailMessage = userMessage({ id: 'msg-1' })
groupChatApiMock.getRoomDetail.mockResolvedValue({
room,
messages: [detailMessage],
agents: [agent],
members: [member],
total: 5,
hasMore: true,
})
groupChatApiMock.socket.emit.mockImplementation((event: string, data?: any, ack?: Function) => {
if (event === 'join' && ack) ack({
roomName: 'Realtime Room',
members: [{ ...member, name: 'Realtime User' }],
agents: [agent],
typingUsers: [],
contextStatuses: [{ agentName: 'Agent', status: 'replying' }],
})
return groupChatApiMock.socket
})
await store.connect()
await store.joinRoom('room-1')
expect(groupChatApiMock.getRoomDetail).toHaveBeenCalledWith('room-1')
expect(groupChatApiMock.socket.emit).toHaveBeenCalledWith('join', expect.objectContaining({ roomId: 'room-1' }), expect.any(Function))
expect(store.currentRoomId).toBe('room-1')
expect(store.roomName).toBe('Realtime Room')
expect(store.messages.map((m: ChatMessage) => m.id)).toEqual(['msg-1'])
expect(store.members.map((m: MemberInfo) => m.name)).toEqual(['Realtime User'])
expect(store.agents.map((a: RoomAgent) => a.agentId)).toEqual(['agent-1'])
expect(store.totalMessages).toBe(5)
expect(store.hasMoreBefore).toBe(true)
expect(store.contextStatuses.get('Agent')).toEqual({ agentName: 'Agent', status: 'replying' })
})
it('joins invite rooms over realtime before fetching protected detail when the socket starts disconnected', async () => {
const store = await loadStore()
const order: string[] = []
groupChatApiMock.socket.connected = false
groupChatApiMock.getSocket.mockImplementation((options?: { requireConnected?: boolean }) => (
groupChatApiMock.socket.connected || options?.requireConnected === false ? groupChatApiMock.socket : null
))
groupChatApiMock.socket.once.mockImplementation((event: string, cb: Function) => {
if (event === 'connect') {
setTimeout(() => {
groupChatApiMock.socket.connected = true
cb()
}, 0)
}
return groupChatApiMock.socket
})
groupChatApiMock.socket.emit.mockImplementation((event: string, data?: any, ack?: Function) => {
if (event === 'join' && ack) {
order.push(data?.inviteCode ? 'invite-join' : 'detail-join')
ack({ roomName: 'Realtime Room', members: [member], agents: [], typingUsers: [], contextStatuses: [] })
}
return groupChatApiMock.socket
})
groupChatApiMock.joinRoomByCode.mockResolvedValue({ room })
groupChatApiMock.getRoomDetail.mockImplementation(async () => {
order.push('detail')
return { room, messages: [], agents: [], members: [member], total: 0, hasMore: false }
})
await store.joinByCode('ROOM1')
expect(groupChatApiMock.connectGroupChat).toHaveBeenCalled()
expect(groupChatApiMock.getRoomDetail).toHaveBeenCalledWith('room-1')
expect(order).toEqual(['invite-join', 'detail', 'detail-join'])
expect(store.currentRoomId).toBe('room-1')
})
it('sends text-only messages through the room socket', async () => {
const store = await loadStore()
await store.connect()
await store.joinRoom('room-1')
await store.sendMessage('hello room')
expect(groupChatApiMock.socket.emit).toHaveBeenCalledWith('message', expect.objectContaining({
roomId: 'room-1',
content: 'hello room',
}), expect.any(Function))
expect(store.error).toBeNull()
})
it('clears local room context from API response and room_cleared event', async () => {
const store = await loadStore()
groupChatApiMock.getRoomDetail.mockResolvedValue({
room,
messages: [userMessage()],
agents: [agent],
members: [member],
total: 1,
hasMore: false,
})
await store.connect()
await store.joinRoom('room-1')
store.rooms = [room]
emitSocket('typing', { roomId: 'room-1', userId: 'user-2', userName: 'Bob' })
store.contextStatuses.set('Agent', { agentName: 'Agent', status: 'replying' })
store.pendingApprovals.set('approval-1', {
roomId: 'room-1',
agentName: 'Agent',
approvalId: 'approval-1',
command: 'touch file',
description: 'needs approval',
choices: ['once', 'session', 'deny'],
allowPermanent: false,
isMemoryWrite: false,
requestedAt: 1,
})
await store.clearCurrentRoomContext()
expect(groupChatApiMock.clearRoomContext).toHaveBeenCalledWith('room-1')
expect(store.messages).toEqual([])
expect(store.typingNames).toEqual([])
expect(store.contextStatuses.size).toBe(0)
expect(store.rooms[0].totalTokens).toBe(0)
emitSocket('room_cleared', { roomId: 'room-1', totalTokens: 0 })
expect(store.pendingApprovals.size).toBe(0)
})
it('tracks pending approvals and removes them when resolved', async () => {
const store = await loadStore()
await store.connect()
await store.joinRoom('room-1')
emitSocket('approval.requested', {
roomId: 'room-1',
agentName: 'Agent',
approval_id: 'approval-1',
command: 'touch file',
description: 'needs approval',
choices: ['once', 'session', 'deny'],
})
expect(store.pendingApprovals.get('approval-1')).toMatchObject({
roomId: 'room-1',
agentName: 'Agent',
approvalId: 'approval-1',
choices: ['once', 'session', 'deny'],
})
emitSocket('approval.resolved', { approval_id: 'approval-1' })
expect(store.pendingApprovals.size).toBe(0)
})
it('updates the current room token display on room_updated', async () => {
const store = await loadStore()
store.rooms = [{ ...room, totalTokens: 7 }]
await store.connect()
emitSocket('room_updated', { roomId: 'room-1', totalTokens: 42 })
expect(store.rooms[0].totalTokens).toBe(42)
})
})