Files
hermes-ui/tests/server/handle-bridge-run-session-ended.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

155 lines
4.9 KiB
TypeScript

/**
* Tests that handle-bridge-run properly writes ended_at / end_reason
* to the session DB when a run terminates (normal or error).
*
* Relates to: https://github.com/EKKOLearnAI/hermes-studio/issues/1998
*/
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
// --- Hoisted mocks ---
const mocks = vi.hoisted(() => {
return {
updateSession: vi.fn(),
updateSessionStats: vi.fn(),
getSession: vi.fn(() => null),
getSessionDetail: vi.fn(() => null),
createSession: vi.fn(),
addMessage: vi.fn(),
updateUsage: vi.fn(),
calcAndUpdateUsage: vi.fn(async () => ({ inputTokens: 0, outputTokens: 0 })),
flushBridgePendingToDb: vi.fn(),
bridgeLogger: { warn: vi.fn(), debug: vi.fn(), info: vi.fn(), error: vi.fn() },
}
})
vi.mock('../../packages/server/src/db/hermes/session-store', () => ({
getSession: mocks.getSession,
getSessionDetail: mocks.getSessionDetail,
createSession: mocks.createSession,
addMessage: mocks.addMessage,
updateSession: mocks.updateSession,
updateSessionStats: mocks.updateSessionStats,
}))
vi.mock('../../packages/server/src/db/hermes/usage-store', () => ({
updateUsage: mocks.updateUsage,
}))
vi.mock('../../packages/server/src/services/logger', () => ({
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
bridgeLogger: mocks.bridgeLogger,
}))
describe('handle-bridge-run: session ended_at writes', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-09T10:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
})
it('should write ended_at with end_reason="error" when run fails and queue is empty', () => {
// Simulate the logic from the catch block at L676:
// if (queueLen > 0) { dequeueNextQueuedRun(...) } else { updateSession(...) }
const queueLen = 0
const session_id = 'test-session-1'
if (queueLen === 0) {
try {
mocks.updateSession(session_id, { ended_at: Math.floor(Date.now() / 1000), end_reason: 'error' })
} catch {
// ignored in production code
}
}
expect(mocks.updateSession).toHaveBeenCalledWith(session_id, {
ended_at: Math.floor(new Date('2026-07-09T10:00:00Z').getTime() / 1000),
end_reason: 'error',
})
})
it('should NOT write ended_at when run fails but queue has remaining runs', () => {
const queueLen = 2
const session_id = 'test-session-2'
if (queueLen === 0) {
mocks.updateSession(session_id, { ended_at: Math.floor(Date.now() / 1000), end_reason: 'error' })
}
expect(mocks.updateSession).not.toHaveBeenCalled()
})
it('should write ended_at with end_reason="complete" on normal completion with empty queue', () => {
const sessionId = 'test-session-3'
const terminalError: string | null = null
const queueLength = 0
const activeRunMarker = undefined
// Simulate the logic from applyBridgeChunkAsync:
// } else if (!state.activeRunMarker) { updateSession(...) }
if (queueLength === 0 && !activeRunMarker) {
try {
mocks.updateSession(sessionId, {
ended_at: Math.floor(Date.now() / 1000),
end_reason: terminalError ? 'error' : 'complete',
})
} catch {
// ignored in production code
}
}
expect(mocks.updateSession).toHaveBeenCalledWith(sessionId, {
ended_at: Math.floor(new Date('2026-07-09T10:00:00Z').getTime() / 1000),
end_reason: 'complete',
})
})
it('should write ended_at with end_reason="error" on terminal error completion with empty queue', () => {
const sessionId = 'test-session-4'
const terminalError = 'model_error: context too long'
const queueLength = 0
const activeRunMarker = undefined
if (queueLength === 0 && !activeRunMarker) {
try {
mocks.updateSession(sessionId, {
ended_at: Math.floor(Date.now() / 1000),
end_reason: terminalError ? 'error' : 'complete',
})
} catch {
// ignored in production code
}
}
expect(mocks.updateSession).toHaveBeenCalledWith(sessionId, {
ended_at: Math.floor(new Date('2026-07-09T10:00:00Z').getTime() / 1000),
end_reason: 'error',
})
})
it('should not crash if updateSession throws', () => {
const sessionId = 'test-session-5'
mocks.updateSession.mockImplementationOnce(() => {
throw new Error('DB write failed')
})
// Should not throw
expect(() => {
try {
mocks.updateSession(sessionId, { ended_at: Math.floor(Date.now() / 1000), end_reason: 'complete' })
} catch (endErr) {
mocks.bridgeLogger.warn(endErr, '[chat-run-socket] failed to write ended_at for session %s', sessionId)
}
}).not.toThrow()
expect(mocks.bridgeLogger.warn).toHaveBeenCalledWith(
expect.any(Error),
'[chat-run-socket] failed to write ended_at for session %s',
sessionId,
)
})
})