Files
aiturk-hermes-ide/apps/desktop/src/plugins/hermes-bots/bot-row.test.tsx
T

178 lines
6.0 KiB
TypeScript

/**
* The bot row's two side effects: pre-warming and opening.
*
* Pre-warm is per-row and hover-scoped. Warming the whole roster on paint
* spun up every profile backend the moment the Bots rail rendered, so the row
* warms exactly one bot and only once a pointer is actually over it — and a
* source-scoped row pre-dials its OWN source rather than the active gateway.
*
* Opening is delegated whole: the row hands its exact roster row to
* openRosterBot and does nothing else. It never activates a connection
* itself, which is what keeps a remote row from resolving into the same-named
* local bot.
*
* Ported from tests/profile-prewarm.test.mjs, which sliced BotRow out of the
* old plugin.js bundle and rendered it against a hand-built jsx stub.
*/
import type * as HermesSdk from '@hermes/plugin-sdk'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { BotRow } from './bot-row'
import { translateBots } from './i18n-test-helper'
import type { RosterRow } from './types'
const { ensureAgent, ensureBotMetadata, notifyError, openRosterBot, requestProfile, warmAgent, warmProfile } =
vi.hoisted(() => ({
ensureAgent: vi.fn(),
ensureBotMetadata: vi.fn(),
notifyError: vi.fn(),
openRosterBot: vi.fn(),
requestProfile: vi.fn(),
warmAgent: vi.fn(),
warmProfile: vi.fn()
}))
vi.mock('@hermes/plugin-sdk', async importOriginal => {
const sdk = await importOriginal<typeof HermesSdk>()
return {
...sdk,
host: { ...sdk.host, ensureAgent, notifyError, requestProfile, warmAgent, warmProfile },
// The plugin bundle normally lands via `ctx.i18n.register` at load, so
// without this every localized label in the row renders empty.
usePluginI18n: () => translateBots
}
})
vi.mock('./canonical-chat', () => ({
ensureBotMetadata,
notifyBotOpenFailure: vi.fn(),
openBotCanonicalChat: vi.fn(),
prepareBotSource: vi.fn(),
PROFILE_SESSION_LIST_LIMIT: 200
}))
vi.mock('./roster-actions', () => ({ openRosterBot }))
const noop = () => undefined
function renderRow(bot: RosterRow) {
render(<BotRow bot={bot} onDelete={noop} onEdit={noop} onGroup={noop} onNewSection={noop} />)
return screen.getByRole('button')
}
beforeEach(() => {
vi.clearAllMocks()
ensureBotMetadata.mockResolvedValue({ pinned: true })
openRosterBot.mockResolvedValue(true)
requestProfile.mockResolvedValue({})
})
describe('pre-warm is hover-scoped, never roster-wide', () => {
it('warms nothing on paint and exactly the hovered bot on pointer entry', async () => {
const row = renderRow({ name: 'alpha' } as RosterRow)
expect(warmProfile).not.toHaveBeenCalled()
fireEvent.pointerEnter(row)
expect(warmProfile.mock.calls).toEqual([['alpha']])
expect(warmAgent).not.toHaveBeenCalled()
})
it('pre-dials a source-scoped row on its own source', async () => {
const row = renderRow({
connectionId: 'work',
connectionLabel: 'Work',
name: 'research',
remoteSource: true,
sourceScoped: true
} as RosterRow)
fireEvent.pointerEnter(row)
expect(warmAgent.mock.calls).toEqual([['work', 'research']])
expect(warmProfile).not.toHaveBeenCalled()
})
})
describe('the row delegates the open and claims no activation authority', () => {
it('hands a remote Connections row to openRosterBot without activating it', async () => {
const bot = {
connectionId: 'work',
connectionLabel: 'Work',
name: 'research',
remoteSource: true,
sourceScoped: true
} as RosterRow
fireEvent.click(renderRow(bot))
expect(ensureAgent).not.toHaveBeenCalled()
expect(openRosterBot.mock.calls).toEqual([[bot]])
})
it('never resolves a remote default into the same-named local bot', async () => {
const bot = {
connectionId: 'mac-mini',
connectionLabel: 'Mac Mini',
name: 'default',
remoteSource: true,
sourceScoped: true
} as RosterRow
fireEvent.click(renderRow(bot))
expect(ensureAgent).not.toHaveBeenCalled()
expect(openRosterBot.mock.calls[0][0].connectionId).toBe('mac-mini')
expect(notifyError).not.toHaveBeenCalled()
})
})
describe('the menu opens the same forever-chat a row click does', () => {
it('opens the canonical chat', async () => {
const bot = { name: 'alpha' } as RosterRow
fireEvent.contextMenu(renderRow(bot))
fireEvent.click(await screen.findByText('Open Bot Chat'))
expect(openRosterBot.mock.calls).toEqual([[bot]])
})
})
describe('context-menu mutations hydrate the alias first', () => {
it('reads the backend row before toggling pin, and writes to the alias target', async () => {
// A non-identity alias (Desktop calls it `worker`, the backend calls it
// `backend-worker`) must have its CURRENT state hydrated from its own
// source before the toggle — flipping a locally-assumed value would
// fight whatever the backend actually holds.
const bot = {
connectionId: 'remote-a',
name: 'worker',
remoteSource: true,
route: { connectionId: 'remote-a', mode: 'remote', profile: 'worker', targetProfile: 'backend-worker' },
sourceScoped: true
} as RosterRow
fireEvent.contextMenu(renderRow(bot))
// The label reads from LOCAL meta (unpinned here); the toggle reads from
// the hydrated backend row, which says pinned. That divergence is the
// point — an alias whose state lives elsewhere must not be flipped
// against a locally-assumed value.
fireEvent.click(await screen.findByText('Pin to top'))
await vi.waitFor(() =>
expect(requestProfile.mock.calls.some(([, method]) => method === 'profiles.configure')).toBe(true)
)
expect(ensureBotMetadata).toHaveBeenCalledWith(bot)
const [route, , params] = requestProfile.mock.calls.find(([, method]) => method === 'profiles.configure')!
expect(route.profile).toBe('worker')
expect(params).toMatchObject({ name: 'backend-worker', ui_meta: { 'hermes-bots': { pinned: false } } })
})
})