63 lines
3.7 KiB
TypeScript
63 lines
3.7 KiB
TypeScript
/** Runtime health reporting: per-component error states and recovery on subsequent success. */
|
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
|
import { Commands } from '../src/infra/commands.ts'
|
|
import { feishu, startRuntime, type Host } from './fixtures/host.ts'
|
|
|
|
let h: Host
|
|
beforeEach(async () => { h = await startRuntime() })
|
|
afterEach(async () => { await h.stop() })
|
|
|
|
/** Advance fake `Date.now()` by `ms` and pump due runtime work. */
|
|
async function at(ms: number) {
|
|
vi.useFakeTimers({ toFake: ['Date'], now: Date.now() + ms })
|
|
try { await h.runtime.pump(); await h.runtime.idle() } finally { vi.useRealTimers() }
|
|
}
|
|
/** Retry a failed effect until it failed three times, after which its kind reports an error. */
|
|
async function failThrice() { await at(2000); await at(6000) }
|
|
|
|
it('reports a relay in error once it failed three times, clears it once the relay reached the main Session, and leaves the runtime ready', async () => {
|
|
const steer = h.agents.get(h.runtime.masterId)!.steer
|
|
for (let count = 0; count < 3; count++) steer.mockImplementationOnce(() => { throw new Error('session busy') })
|
|
h.runtime.sessions.relay('r1', JSON.stringify({ kind: 'note' }))
|
|
await h.runtime.idle()
|
|
expect(h.runtime.health.relay).toMatchObject({ state: 'ready', detail: expect.stringContaining('1 waiting') })
|
|
await failThrice()
|
|
expect(h.runtime.health.relay).toMatchObject({ state: 'error', detail: expect.stringContaining('session busy') })
|
|
await at(14_000)
|
|
expect(h.runtime.health).toMatchObject({ relay: { state: 'ready', detail: 'Nothing waiting' }, runtime: { state: 'ready' } })
|
|
})
|
|
|
|
it('keeps a failing relay\'s error while replies recover, and a failing reply\'s error while relays recover', async () => {
|
|
const master = h.agents.get(h.runtime.masterId)!
|
|
const lark = vi.mocked(Commands.prototype.lark)
|
|
const reply = (id: string) => { h.runtime.outbox.send({ id, channel: feishu('9', 'x').channel!, text: '已合并。', kind: 'notice' }) }
|
|
master.steer.mockImplementation(() => { throw new Error('master gone') })
|
|
h.runtime.sessions.relay('r2', JSON.stringify({ kind: 'note' }))
|
|
lark.mockRejectedValue(new Error('internal error (2200)'))
|
|
reply('stuck')
|
|
// Each fails three times in a row: the relay after 0, 2 and 6 seconds, the reply after 0, 5 and 15.
|
|
await h.runtime.idle(); await at(5000); await at(15_000)
|
|
expect(h.runtime.health).toMatchObject({ relay: { state: 'error' }, reply: { state: 'error', detail: expect.stringContaining('2200') } })
|
|
lark.mockResolvedValue({})
|
|
await at(35_000)
|
|
expect(h.runtime.health).toMatchObject({ relay: { state: 'error' }, reply: { state: 'ready' } })
|
|
lark.mockRejectedValue(new Error('internal error (2200)'))
|
|
reply('stuck-2')
|
|
master.steer.mockImplementation(() => {})
|
|
await h.runtime.idle(); await at(50_000); await at(70_000)
|
|
expect(h.runtime.health).toMatchObject({ relay: { state: 'ready' }, reply: { state: 'error' }, runtime: { state: 'ready' } })
|
|
})
|
|
|
|
it('reports a notice the main Session did not receive three times, until one reaches it, and injects each notice once', async () => {
|
|
const inject = h.agents.get(h.runtime.masterId)!.inject
|
|
for (let count = 0; count < 3; count++) inject.mockImplementationOnce(() => { throw new Error('session busy') })
|
|
await h.runtime.settings.pause(true, 'panel')
|
|
await failThrice()
|
|
expect(h.runtime.health.notice).toMatchObject({ state: 'error', detail: expect.stringContaining('session busy') })
|
|
inject.mockClear()
|
|
await h.runtime.settings.pause(false, 'panel')
|
|
expect(h.runtime.health).toMatchObject({ notice: { state: 'ready' }, runtime: { state: 'ready' } })
|
|
// The waiting pause notice was updated in place by the resume value and injected once.
|
|
expect(inject).toHaveBeenCalledOnce()
|
|
})
|