Files
hermes-ui/tests/server/handle-bridge-run-session-ended.test.ts
T
toor cc644c59f6 Phase 008: close the test and CI loop around the migration
Adds the checks that make the constraints from Phase 001-007 mechanical
instead of tribal knowledge, and clears the legacy brand text that was still
reachable at runtime.

New red-line tests:
  - no-legacy-brand mirrors the harness scan under vitest, and asserts its
    allow-list has not drifted from harness-check.mjs
  - hermes-db-readonly classifies every new DatabaseSync call site by
    following the path it opens, and fails if a state.db handle drops
    readOnly. It also pins the one legitimately writable database.
  - i18n-parity compares the raw zh/en locale objects both ways. The
    existing coverage test merged en into zh at import time, so a key
    missing from zh could never fail there.

harness:check gains the four missing gates from the phase brief - retired
package directories, retired package scripts, a single UI library, and the
required root files. Each was verified to fail by mutating the repo.

The online-update channel had hardcoded upstream URLs. It now reads
HERMES_WEB_UI_VERSION_MANIFEST_URL and friends and degrades with a named
error, because a reimplementation has nowhere to pull releases from.

The remaining legacy strings are deliberate and each carries a reason in the
allow-list: the global-agent relay address and the OpenRouter referer are
load-bearing, the socket role identifier is a wire contract, and the pre-rename
DB filenames would orphan existing deployments if changed. AGENTS.md now says
so explicitly, so a future agent does not "clean them up".

Also removes a dead electron mock alias, an i18n exemption that had stopped
exempting anything, and a teardown race in plugins-view.test.ts where a-spin
animation frames outlived the jsdom environment.

Verified self-contained: with reference_code/ moved aside, harness:check,
build, test and test:e2e all pass, so the directory can now be deleted.
2026-10-19 00:23:19 +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: upstream issue #1998 (session ended_at / end_reason not persisted)
*/
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,
)
})
})