test(desktop): cover bot reconciliation runtime lifecycle

This commit is contained in:
fangliquanflq
2026-08-26 02:08:47 +08:00
committed by Teknium
parent 66186dc58f
commit ef6532c25c
2 changed files with 100 additions and 117 deletions
@@ -0,0 +1,100 @@
import type * as HermesSdk from '@hermes/plugin-sdk'
import { atom } from 'nanostores'
import { afterEach, describe, expect, it, vi } from 'vitest'
const gatewayState = atom<'closed' | 'open'>('closed')
const listPersistedSessions = vi.fn(
async (_route: unknown, options: { profile: string }) => ({
sessions: [{ id: `${options.profile}-bot`, profile: options.profile, started_at: 1, title: 'Bot Chat' }]
})
)
const setPersistedSessionHidden = vi.fn(
async (_route: unknown, _options: { hidden: boolean; profile: string; sessionId: string }) => ({ hidden: true, ok: true })
)
const request = vi.fn(async (method: string) =>
method === 'profiles.list' ? { profiles: [{ name: 'alpha' }, { name: 'beta' }] } : {}
)
vi.mock('@hermes/plugin-sdk', async importOriginal => {
const sdk = await importOriginal<typeof HermesSdk>()
return {
...sdk,
host: {
...sdk.host,
listPersistedSessions,
onEvent: vi.fn(() => () => undefined),
profileRoutes: vi.fn(async () => []),
request,
setPersistedSessionHidden,
state: {
...sdk.host.state,
gateway: gatewayState,
profile: atom('default')
}
}
}
})
const { createPluginContext } = await import('@/contrib/plugin')
// @ts-expect-error The bundled Bot Mode entry intentionally remains plain ESM JavaScript for older runtime loaders.
const { default: plugin } = await import('../plugin.js')
const flushSweep = async () => {
await vi.advanceTimersByTimeAsync(0)
await Promise.resolve()
}
afterEach(() => {
gatewayState.set('closed')
vi.clearAllMocks()
vi.useRealTimers()
})
describe('Bot Mode hidden-session reconciliation lifecycle', () => {
it('uses persisted REST on load/reconnect and stops with plugin disposal', async () => {
vi.useFakeTimers()
const disposers: Array<() => void> = []
plugin.register(createPluginContext(plugin.id, dispose => disposers.push(dispose)))
gatewayState.set('open')
await flushSweep()
gatewayState.set('closed')
gatewayState.set('open')
await flushSweep()
gatewayState.set('closed')
gatewayState.set('open')
await flushSweep()
expect(listPersistedSessions).toHaveBeenCalledTimes(6)
expect(listPersistedSessions.mock.calls.map(([, options]) => options.profile)).toEqual([
'alpha',
'beta',
'alpha',
'beta',
'alpha',
'beta'
])
expect(setPersistedSessionHidden).toHaveBeenCalledTimes(6)
expect(setPersistedSessionHidden.mock.calls.map(([, options]) => options)).toEqual(
expect.arrayContaining([
expect.objectContaining({ hidden: true, profile: 'alpha', sessionId: 'alpha-bot' }),
expect.objectContaining({ hidden: true, profile: 'beta', sessionId: 'beta-bot' })
])
)
expect(request.mock.calls.some(([method]) => method === 'session.list' || method === 'session.set_hidden')).toBe(false)
disposers.forEach(dispose => dispose())
const readsAtDispose = listPersistedSessions.mock.calls.length
gatewayState.set('closed')
gatewayState.set('open')
await flushSweep()
expect(listPersistedSessions).toHaveBeenCalledTimes(readsAtDispose)
})
})
@@ -298,123 +298,6 @@ test('sweepBotProfileSessions hides Bot-Mode-titled rows per roster bot, and onl
assert.equal(hidden.find(c => c.params.sessionId === 'r-1').route.connectionId, 'mini')
})
test('load reconciliation uses persisted REST doors and never wakes profile gateways', async () => {
const start = source.indexOf('function hideOwnedBotSessions()')
const end = source.indexOf('/** Fetch server-side avatars', start)
const reads = []
const writes = []
const context = {
host: {
request: async () => ({}),
listPersistedSessions: async (route, options) => {
reads.push({ route, options })
return { sessions: [{ id: `${options.profile}-bot`, profile: options.profile, title: 'Bot Chat', started_at: 1 }] }
},
setPersistedSessionHidden: async (route, options) => writes.push({ route, options })
},
$groupChats: { get: () => ({}) },
$lastRoster: {
get: () => [
{
name: 'alpha',
sourceScoped: true,
route: { connectionId: 'local', mode: 'local', profile: 'alpha', targetProfile: 'alpha' }
},
{
name: 'remy',
sourceScoped: true,
route: { connectionId: 'mini', mode: 'remote', profile: 'remy', targetProfile: 'worker' }
}
]
},
PROFILE_SESSION_LIST_LIMIT: 200,
backendTargetProfile: (route, fallback) => route?.targetProfile || fallback,
botConnectionRoute: bot => bot.route || null,
groupMemberKey: member => member.name,
requestForBot: async () => {
throw new Error('reconciliation must not use gateway RPC')
}
}
const section = source.slice(start, end).concat('\nglobalThis.__h = { hideOwnedBotSessions, sweepBotProfileSessions };\n')
vm.runInNewContext(section, context, { filename: 'persisted-sweep.js' })
await context.__h.hideOwnedBotSessions()
assert.deepEqual(reads.map(call => call.options.profile).sort(), ['alpha', 'worker'])
assert.deepEqual(writes.map(call => call.options.sessionId).sort(), ['alpha-bot', 'worker-bot'])
assert.ok(writes.every(call => call.options.hidden === true))
})
test('hide-sweep scheduling coalesces reconnects, queues one trailing run, and cancels on dispose', async () => {
const start = source.indexOf('function startHideSweepScheduler(')
const end = source.indexOf('function hideOwnedBotSessions()', start)
const timers = new Map()
let nextTimer = 1
let gatewayListener = null
let dispose = null
let stopped = 0
let resolveFirst
let sweeps = 0
const context = {
host: {
state: {
gateway: {
listen: listener => {
gatewayListener = listener
return () => {
stopped += 1
}
}
}
}
},
hideOwnedBotSessions: () => {
sweeps += 1
return sweeps === 1 ? new Promise(resolve => (resolveFirst = resolve)) : Promise.resolve()
},
setTimeout: callback => {
const id = nextTimer++
timers.set(id, callback)
return id
},
clearTimeout: id => timers.delete(id)
}
const ctx = { onDispose: callback => (dispose = callback) }
const runNextTimer = () => {
const [id, callback] = timers.entries().next().value
timers.delete(id)
callback()
}
const section = source.slice(start, end).concat('\nglobalThis.__scheduler = startHideSweepScheduler;\n')
vm.runInNewContext(section, context, { filename: 'hide-scheduler.js' })
context.__scheduler(ctx)
gatewayListener('open')
gatewayListener('open')
assert.equal(timers.size, 1, 'load plus duplicate open events coalesce before execution')
runNextTimer()
await Promise.resolve()
assert.equal(sweeps, 1)
gatewayListener('open')
runNextTimer()
assert.equal(sweeps, 1, 'an inflight sweep is not overlapped')
resolveFirst()
await new Promise(resolve => setImmediate(resolve))
assert.equal(timers.size, 1, 'one trailing sweep preserves the reconnect signal')
runNextTimer()
await Promise.resolve()
assert.equal(sweeps, 2)
gatewayListener('open')
assert.equal(timers.size, 1)
dispose()
assert.equal(timers.size, 0)
assert.equal(stopped, 1)
})
test('hideOwnedBotSessions chains the ownership sweep and survives its absence of context', async () => {
// The load/reconnect entrypoint runs BOTH halves: known ids first, then
// the roster-wide title sweep (best-effort — a throwing sweep never