test(desktop): cover bot reconciliation runtime lifecycle
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user